Compare commits
245
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc2cdca549 | ||
|
|
ee01ab9427 | ||
|
|
cebbde7bde | ||
|
|
a0b80dd27d | ||
|
|
839da80e0b | ||
|
|
f0e9343d74 | ||
|
|
bf6d98195e | ||
|
|
846b2d9646 | ||
|
|
6df776b25a | ||
|
|
1074f89c47 | ||
|
|
726cc132af | ||
|
|
078c1793a9 | ||
|
|
b83e2c2f37 | ||
|
|
a2fa57456d | ||
|
|
64937df8a2 | ||
|
|
6527e66c07 | ||
|
|
07b611d07d | ||
|
|
dcedf9582a | ||
|
|
f2c420d9c0 | ||
|
|
68cd1c120a | ||
|
|
993f30456f | ||
|
|
aa95e42383 | ||
|
|
75e470bfa4 | ||
|
|
0ac67f5092 | ||
|
|
837cc02812 | ||
|
|
1bce694ebb | ||
|
|
c4855526fe | ||
|
|
298595069d | ||
|
|
f636c5d505 | ||
|
|
0f43870e6c | ||
|
|
d1fbcd9b0a | ||
|
|
b5a9deb815 | ||
|
|
d0ca53501c | ||
|
|
790da4bd0f | ||
|
|
cc2e055e09 | ||
|
|
549c6180a2 | ||
|
|
ec644ab90f | ||
|
|
f0fdc23cc9 | ||
|
|
9f2edf6b7a | ||
|
|
3a21243be7 | ||
|
|
2a017623e9 | ||
|
|
b59c74adfe | ||
|
|
371be4a69c | ||
|
|
83bb589ea6 | ||
|
|
144c4a2872 | ||
|
|
5b2a11b8c7 | ||
|
|
705e2436ba | ||
|
|
87769cbfbf | ||
|
|
bd567cd165 | ||
|
|
7a76d32e4b | ||
|
|
0947ecee11 | ||
|
|
ef601c6d26 | ||
|
|
87d0d53205 | ||
|
|
d8d014bfd9 | ||
|
|
c10f2ac22e | ||
|
|
3ca1fadfea | ||
|
|
7c458ede8e | ||
|
|
27a6199939 | ||
|
|
2c93e25faf | ||
|
|
d4c0587df0 | ||
|
|
ab56054aeb | ||
|
|
d2d2b9dd68 | ||
|
|
56752ebfc0 | ||
|
|
6de8173d18 | ||
|
|
1f3b03bc6d | ||
|
|
75b78325e4 | ||
|
|
be3ebd7fe0 | ||
|
|
06cf80d4a2 | ||
|
|
1ea3f8d65c | ||
|
|
e456c9701b | ||
|
|
3aea8c5bfa | ||
|
|
f14829542b | ||
|
|
1843739e0c | ||
|
|
9fa56a8274 | ||
|
|
082946aa30 | ||
|
|
83b77796fc | ||
|
|
a569104620 | ||
|
|
2523c9e3dd | ||
|
|
f0cb91ed76 | ||
|
|
7e84434ff6 | ||
|
|
27f11bf85a | ||
|
|
981a86cc26 | ||
|
|
b943ca5db2 | ||
|
|
cb3d567b7d | ||
|
|
0fef808671 | ||
|
|
ee46a856de | ||
|
|
b037a121d0 | ||
|
|
4c4cf6d8b4 | ||
|
|
45ac9be965 | ||
|
|
ab6fcef6f3 | ||
|
|
c7cd068e1a | ||
|
|
82cfc8ccba | ||
|
|
3a9d1db763 | ||
|
|
67609eea91 | ||
|
|
9c025b4cea | ||
|
|
ef2991a117 | ||
|
|
9a518db7b8 | ||
|
|
aa9e0f02b7 | ||
|
|
edd03e542d | ||
|
|
774ca28847 | ||
|
|
b602a9cea5 | ||
|
|
4576964be4 | ||
|
|
c481afc7d9 | ||
|
|
921363542c | ||
|
|
82659e9f4e | ||
|
|
47c16971a7 | ||
|
|
b08e4c4268 | ||
|
|
1278caa249 | ||
|
|
8a62ae008c | ||
|
|
9da66da776 | ||
|
|
34b1fdc1a3 | ||
|
|
2943fd0c5e | ||
|
|
486f1a061c | ||
|
|
dd0fac0e15 | ||
|
|
83dbd25c50 | ||
|
|
386d4bfc3f | ||
|
|
bf24bbc15a | ||
|
|
987a961f4a | ||
|
|
a50b6df21b | ||
|
|
8427e219ea | ||
|
|
c0d41cf8cf | ||
|
|
eb55c88e1a | ||
|
|
31fe91b99a | ||
|
|
b9cc4bd780 | ||
|
|
6c92eacba0 | ||
|
|
602b9cd3df | ||
|
|
5c8707432b | ||
|
|
4cac6bc835 | ||
|
|
0801dd6632 | ||
|
|
1c6dc153ce | ||
|
|
f2e3710c28 | ||
|
|
ed4931064b | ||
|
|
1db720af13 | ||
|
|
8c3c79543e | ||
|
|
7aa1ca013f | ||
|
|
5af9a22b98 | ||
|
|
786498a57a | ||
|
|
790ad154f3 | ||
|
|
0c8991b519 | ||
|
|
e2c2f942c2 | ||
|
|
937ba7e115 | ||
|
|
e056c2477b | ||
|
|
7bd22f1f80 | ||
|
|
cfb0e4735a | ||
|
|
95f9a805b1 | ||
|
|
640dc87a5f | ||
|
|
327a4e34dd | ||
|
|
bf2793be7b | ||
|
|
1973d76427 | ||
|
|
403fa6eff3 | ||
|
|
3214d6aff3 | ||
|
|
459046b21c | ||
|
|
91adc281ca | ||
|
|
a9c4e54023 | ||
|
|
8c8e4d7a29 | ||
|
|
9d3347463a | ||
|
|
d462e44453 | ||
|
|
1af583e1ab | ||
|
|
2fac63e58c | ||
|
|
2999ab62ea | ||
|
|
5b052372b7 | ||
|
|
4232424b23 | ||
|
|
60fe761def | ||
|
|
9b9fa9cdee | ||
|
|
329e7811eb | ||
|
|
21aaacc8b4 | ||
|
|
ab85827187 | ||
|
|
bea745047d | ||
|
|
a483fe4baa | ||
|
|
0ed892a412 | ||
|
|
bb808df89a | ||
|
|
c800293f1f | ||
|
|
340b981b79 | ||
|
|
c49e8fcacd | ||
|
|
495b90782a | ||
|
|
0cfb4dc81c | ||
|
|
b8ac68d844 | ||
|
|
eaf13effd5 | ||
|
|
0339268c43 | ||
|
|
6fd1cf9ba7 | ||
|
|
8d4b309753 | ||
|
|
b11c6c17d1 | ||
|
|
e474a2b4c9 | ||
|
|
00c32688f8 | ||
|
|
d6f108d818 | ||
|
|
6a30ff11bd | ||
|
|
22df3f8f5f | ||
|
|
87853fc29c | ||
|
|
b7c2fd081f | ||
|
|
809b76526e | ||
|
|
760796f650 | ||
|
|
10e4f218a6 | ||
|
|
84b283f5b6 | ||
|
|
8f2e03df2a | ||
|
|
c79afa9541 | ||
|
|
f818f1dcc1 | ||
|
|
de60f7e21e | ||
|
|
881478a873 | ||
|
|
755ba5562d | ||
|
|
182f18ecf3 | ||
|
|
1a3d726eac | ||
|
|
c393b96da3 | ||
|
|
09ec64932f | ||
|
|
9079d404d6 | ||
|
|
af9d531a00 | ||
|
|
136eda16c9 | ||
|
|
626a89bdbc | ||
|
|
68784be4db | ||
|
|
853d51ae14 | ||
|
|
a578834462 | ||
|
|
c31c3765f4 | ||
|
|
bdd5a2c43e | ||
|
|
8eb03d106e | ||
|
|
4da6e3b43c | ||
|
|
7be7420c4f | ||
|
|
34c4e87d14 | ||
|
|
e61c757633 | ||
|
|
cc1f8fba72 | ||
|
|
556f2e7cac | ||
|
|
0898c54765 | ||
|
|
f4368785f0 | ||
|
|
608f4c17f0 | ||
|
|
92c58141af | ||
|
|
7b2f4cb05f | ||
|
|
e65e76cd9d | ||
|
|
6d03ed5a69 | ||
|
|
522c046525 | ||
|
|
56f956973e | ||
|
|
bd69ef41d5 | ||
|
|
eeb08fc78f | ||
|
|
1836b035b4 | ||
|
|
3e01e57c8d | ||
|
|
ca3e2ee0ca | ||
|
|
5859ef77e7 | ||
|
|
f0bd49d03d | ||
|
|
cede77f3bc | ||
|
|
dd8a6cd9d7 | ||
|
|
ab96c97cb9 | ||
|
|
881779005a | ||
|
|
20bc9f250c | ||
|
|
87be717f40 | ||
|
|
75d147b69f | ||
|
|
edaece8716 | ||
|
|
ab27fb97f8 | ||
|
|
d736364ad7 |
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
|
||||
@@ -85,7 +85,14 @@ scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.codex-target-*/
|
||||
.codex-tmp/
|
||||
.pnpm-store/
|
||||
**/__pycache__/
|
||||
*.bak
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||
# app/docs asset path with a descriptive filename.
|
||||
Screenshot *.png
|
||||
uploads/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -145,22 +216,49 @@ 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
|
||||
// handler silently no-ops and "Open in new tab" dies.
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
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 { routeOutbound(url) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { inAppUrl = url }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
@@ -217,16 +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
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -236,25 +328,39 @@ 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,
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: android.os.Message?,
|
||||
): Boolean {
|
||||
// Extract the URL from the hit test
|
||||
val data = view?.hitTestResult?.extra
|
||||
if (data != null) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(data),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport
|
||||
?: return false
|
||||
|
||||
val popup = WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
if (url != null) routeOutbound(url)
|
||||
view?.stopLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
transport.webView = popup
|
||||
resultMsg.sendToTarget()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,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="#FF3A3A3A" />
|
||||
<item android:offset="1" android:color="#FF888888" />
|
||||
</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."
|
||||
+303
@@ -1,5 +1,308 @@
|
||||
# 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.
|
||||
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
|
||||
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
|
||||
|
||||
## v1.7.91-alpha (2026-06-14)
|
||||
|
||||
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
|
||||
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
|
||||
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
|
||||
- The Electrum server app is back on the home screen and can be launched again.
|
||||
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
|
||||
|
||||
## v1.7.90-alpha (2026-06-13)
|
||||
|
||||
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
|
||||
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
|
||||
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
|
||||
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
|
||||
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
|
||||
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
|
||||
|
||||
## v1.7.89-alpha (2026-06-12)
|
||||
|
||||
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
|
||||
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
|
||||
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
|
||||
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
|
||||
- The Lightning wallet now recovers and unlocks itself properly after restarts.
|
||||
|
||||
## v1.7.88-alpha (2026-06-12)
|
||||
|
||||
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
|
||||
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
|
||||
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
|
||||
|
||||
## v1.7.87-alpha (2026-06-12)
|
||||
|
||||
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
|
||||
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
|
||||
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
|
||||
|
||||
## v1.7.86-alpha (2026-06-12)
|
||||
|
||||
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
|
||||
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
|
||||
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
|
||||
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
|
||||
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
|
||||
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
|
||||
|
||||
## v1.7.85-alpha (2026-06-12)
|
||||
|
||||
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
|
||||
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
|
||||
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
|
||||
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
|
||||
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
|
||||
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
|
||||
|
||||
## v1.7.84-alpha (2026-06-11)
|
||||
|
||||
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
|
||||
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
|
||||
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
|
||||
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
|
||||
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
|
||||
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
|
||||
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
|
||||
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
|
||||
|
||||
## v1.7.83-alpha (2026-06-11)
|
||||
|
||||
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
|
||||
- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release.
|
||||
- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing.
|
||||
- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring.
|
||||
- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient.
|
||||
- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy.
|
||||
- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase.
|
||||
|
||||
## v1.7.82-alpha (2026-05-22)
|
||||
|
||||
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
|
||||
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
|
||||
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
|
||||
|
||||
## v1.7.81-alpha (2026-05-21)
|
||||
|
||||
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
|
||||
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
|
||||
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
|
||||
|
||||
## v1.7.80-alpha (2026-05-21)
|
||||
|
||||
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
|
||||
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
|
||||
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
|
||||
|
||||
## v1.7.79-alpha (2026-05-20)
|
||||
|
||||
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
|
||||
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
|
||||
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
|
||||
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
|
||||
|
||||
## v1.7.78-alpha (2026-05-20)
|
||||
|
||||
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
|
||||
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
|
||||
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
|
||||
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
|
||||
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
|
||||
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
|
||||
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
|
||||
|
||||
## v1.7.77-alpha (2026-05-20)
|
||||
|
||||
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
|
||||
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
|
||||
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
|
||||
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
|
||||
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
|
||||
|
||||
## v1.7.76-alpha (2026-05-20)
|
||||
|
||||
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
|
||||
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
|
||||
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
|
||||
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
|
||||
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
|
||||
|
||||
## v1.7.75-alpha (2026-05-19)
|
||||
|
||||
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
|
||||
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
|
||||
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
|
||||
|
||||
## v1.7.74-alpha (2026-05-19)
|
||||
|
||||
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
|
||||
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
|
||||
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
|
||||
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.73-alpha (2026-05-19)
|
||||
|
||||
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||
|
||||
## v1.7.72-alpha (2026-05-19)
|
||||
|
||||
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
|
||||
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
|
||||
|
||||
## v1.7.71-alpha (2026-05-19)
|
||||
|
||||
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
|
||||
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
|
||||
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
|
||||
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
|
||||
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
|
||||
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.70-alpha (2026-05-19)
|
||||
|
||||
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
|
||||
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
|
||||
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
|
||||
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
|
||||
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.69-alpha (2026-05-19)
|
||||
|
||||
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
|
||||
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
|
||||
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
|
||||
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
|
||||
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
|
||||
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
|
||||
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
|
||||
- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.68-alpha (2026-05-19)
|
||||
|
||||
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
|
||||
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
|
||||
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
|
||||
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
|
||||
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
|
||||
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
|
||||
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
|
||||
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
|
||||
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
|
||||
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
|
||||
|
||||
## v1.7.67-alpha (2026-05-18)
|
||||
|
||||
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ echo ""
|
||||
# Install custom app dependencies
|
||||
echo "Installing custom app dependencies..."
|
||||
|
||||
for app in did-wallet endurain morphos-server router web5-dwn; do
|
||||
for app in did-wallet endurain morphos-server router; do
|
||||
if [ -d "apps/$app" ]; then
|
||||
echo " - Installing $app dependencies..."
|
||||
cd "apps/$app"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- **Mempool** block explorer and fee estimator
|
||||
- **Fedimint** federation guardian and gateway
|
||||
|
||||
### Self-Hosted Apps (30)
|
||||
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||
### Self-Hosted Apps (29)
|
||||
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||
|
||||
### Decentralized Identity
|
||||
- Ed25519 node identity with DID Documents (did:key)
|
||||
|
||||
+278
-59
@@ -14,7 +14,7 @@
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
|
||||
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
|
||||
"author": "Bitcoin Knots",
|
||||
"category": "money",
|
||||
@@ -25,8 +25,8 @@
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"version": "28.4",
|
||||
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
|
||||
"version": "28.4.0",
|
||||
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-core.svg",
|
||||
"author": "Bitcoin Core contributors",
|
||||
"category": "money",
|
||||
@@ -38,7 +38,7 @@
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.svg",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"version": "2.3.9",
|
||||
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -68,7 +68,7 @@
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
@@ -84,7 +84,7 @@
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
@@ -99,7 +99,7 @@
|
||||
"id": "indeedhub",
|
||||
"title": "IndeeHub",
|
||||
"version": "1.0.0",
|
||||
"description": "Bitcoin documentary streaming with Nostr identity.",
|
||||
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
@@ -110,49 +110,133 @@
|
||||
"id": "botfights",
|
||||
"title": "BotFights",
|
||||
"version": "1.1.0",
|
||||
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
|
||||
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
|
||||
"icon": "/assets/img/app-icons/botfights.svg",
|
||||
"author": "BotFights",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net",
|
||||
"containerConfig": {
|
||||
"ports": ["9100:9100"],
|
||||
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
|
||||
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
|
||||
"ports": [
|
||||
"9100:9100"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/botfights:/app/server/data"
|
||||
],
|
||||
"env": [
|
||||
"NODE_ENV=production",
|
||||
"PORT=9100",
|
||||
"FIGHT_LOOP_ENABLED=true",
|
||||
"ARCHY_EMBEDDED=1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
|
||||
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": ["3001:3000", "2222:22"],
|
||||
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
|
||||
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
|
||||
}
|
||||
"ports": [
|
||||
"3001:3000",
|
||||
"2222:22"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/gitea/data:/data",
|
||||
"/var/lib/archipelago/gitea/config:/etc/gitea"
|
||||
],
|
||||
"env": [
|
||||
"GITEA__database__DB_TYPE=sqlite3",
|
||||
"GITEA__server__SSH_PORT=2222",
|
||||
"GITEA__server__SSH_LISTEN_PORT=22",
|
||||
"GITEA__server__LFS_START_SERVER=true",
|
||||
"GITEA__packages__ENABLED=true",
|
||||
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
|
||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
|
||||
"GITEA__security__X_FRAME_OPTIONS="
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Web-based file manager.",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": ["8083:80"],
|
||||
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
|
||||
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
|
||||
"ports": [
|
||||
"8083:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/filebrowser:/srv",
|
||||
"/var/lib/archipelago/filebrowser-data:/data"
|
||||
],
|
||||
"args": [
|
||||
"--database=/data/database.db",
|
||||
"--root=/srv",
|
||||
"--address=0.0.0.0",
|
||||
"--port=80"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostr.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
|
||||
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8081:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
|
||||
],
|
||||
"env": [
|
||||
"RELAY_NAME=Archipelago Nostr Relay",
|
||||
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "meshtastic",
|
||||
"title": "Meshtastic",
|
||||
"version": "2-daily-alpine",
|
||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||
"author": "Meshtastic",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||
"repoUrl": "https://github.com/meshtastic/firmware",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"4403:4403"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
||||
],
|
||||
"env": [
|
||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||
"MESHTASTIC_SERIAL=true"
|
||||
],
|
||||
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -167,15 +251,19 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": ["8082:80"],
|
||||
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
|
||||
"ports": [
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"version": "2024.1.0",
|
||||
"description": "Privacy-respecting metasearch engine.",
|
||||
"version": "1.0.0",
|
||||
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
|
||||
"icon": "/assets/img/app-icons/searxng.png",
|
||||
"author": "SearXNG",
|
||||
"category": "data",
|
||||
@@ -183,21 +271,58 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": ["8888:8080"],
|
||||
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint",
|
||||
"version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"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",
|
||||
"version": "0.10.0",
|
||||
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8176:8176",
|
||||
"9737:9737"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/fedimint-gateway:/data",
|
||||
"/var/lib/archipelago/lnd:/lnd:ro"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
@@ -209,8 +334,13 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": ["8096:8096"],
|
||||
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -227,34 +357,47 @@
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2024.1",
|
||||
"description": "Open-source home automation.",
|
||||
"version": "2024.1.0",
|
||||
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": ["8123:8123"],
|
||||
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
|
||||
"env": ["TZ=UTC"]
|
||||
"ports": [
|
||||
"8123:8123"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/home-assistant:/config"
|
||||
],
|
||||
"env": [
|
||||
"TZ=UTC"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"description": "Analytics and monitoring dashboards.",
|
||||
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
|
||||
"icon": "/assets/img/app-icons/grafana.png",
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
|
||||
"dockerImage": "grafana/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": ["3000:3000"],
|
||||
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
|
||||
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
|
||||
"ports": [
|
||||
"3000:3000"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||
],
|
||||
"env": [
|
||||
"GF_PATHS_DATA=/var/lib/grafana",
|
||||
"GF_USERS_ALLOW_SIGN_UP=false"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -269,10 +412,65 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale",
|
||||
"containerConfig": {
|
||||
"ports": ["8240:8240"],
|
||||
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
|
||||
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
|
||||
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
|
||||
"ports": [
|
||||
"8240:8240"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
|
||||
],
|
||||
"env": [
|
||||
"TS_STATE_DIR=/var/lib/tailscale"
|
||||
],
|
||||
"args": [
|
||||
"sh",
|
||||
"-c",
|
||||
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "portainer",
|
||||
"title": "Portainer",
|
||||
"version": "2.19.4",
|
||||
"description": "Container management web UI for the local Podman socket.",
|
||||
"icon": "/assets/img/app-icons/portainer.webp",
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"9000:9000"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/portainer:/data",
|
||||
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
|
||||
],
|
||||
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "0.71.2",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8087:80",
|
||||
"8086:80",
|
||||
"3478:3478/udp"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/netbird:/var/lib/netbird"
|
||||
],
|
||||
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -287,10 +485,20 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma",
|
||||
"containerConfig": {
|
||||
"ports": ["3002:3001"],
|
||||
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
|
||||
"env": ["TZ=UTC"],
|
||||
"args": ["--", "node", "server/server.js"]
|
||||
"ports": [
|
||||
"3002:3001"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/uptime-kuma:/app/data"
|
||||
],
|
||||
"env": [
|
||||
"TZ=UTC"
|
||||
],
|
||||
"args": [
|
||||
"--",
|
||||
"node",
|
||||
"server/server.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -304,24 +512,35 @@
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"containerConfig": {
|
||||
"ports": ["2342:2342"],
|
||||
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
|
||||
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
|
||||
"ports": [
|
||||
"2342:2342"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||
],
|
||||
"env": [
|
||||
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
|
||||
"PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "28",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": ["8085:80"],
|
||||
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@
|
||||
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
||||
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||
@@ -33,7 +32,6 @@
|
||||
| ollama | 11434 | v0.5.4 |
|
||||
| grafana | 3001 | v10.2.0 |
|
||||
| portainer | 9000 | v2.19.4 |
|
||||
| onlyoffice | 8088 | v7.5.1 |
|
||||
| penpot | 8089 | v2.4 |
|
||||
|
||||
## Building Apps
|
||||
@@ -44,7 +42,7 @@ cd apps
|
||||
./build.sh <app-id> # Build specific app
|
||||
```
|
||||
|
||||
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
||||
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
|
||||
|
||||
## App Structure
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ This document lists all port assignments for Archipelago apps.
|
||||
| mempool | 4080 | TCP | Web UI | 14080 |
|
||||
| ollama | 11434 | TCP | API | 21434 |
|
||||
| searxng | 8888 | TCP | Web UI | 18888 |
|
||||
| onlyoffice | 8088 | TCP | Web UI | 18088 |
|
||||
| penpot | 8089 | TCP | Web UI | 18089 |
|
||||
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
|
||||
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
|
||||
@@ -25,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
|
||||
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
@@ -47,7 +45,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
||||
| Mempool | http://localhost:14080 |
|
||||
| Ollama | http://localhost:21434 |
|
||||
| SearXNG | http://localhost:18888 |
|
||||
| OnlyOffice | http://localhost:18088 |
|
||||
| Penpot | http://localhost:18089 |
|
||||
| LND REST | http://localhost:18080 |
|
||||
| Core Lightning | http://localhost:19835 |
|
||||
@@ -55,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
||||
| Strfry | http://localhost:18082 |
|
||||
| DID Wallet | http://localhost:18083 |
|
||||
| Router | http://localhost:18084 |
|
||||
| Web5 DWN | http://localhost:13000 |
|
||||
| Meshtastic | http://localhost:14403 |
|
||||
|
||||
## Port Conflict Resolution
|
||||
|
||||
+2
-4
@@ -30,14 +30,13 @@ cd apps
|
||||
./build.sh
|
||||
```
|
||||
|
||||
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
|
||||
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
|
||||
|
||||
### Build Specific App
|
||||
|
||||
```bash
|
||||
./build.sh router
|
||||
./build.sh did-wallet
|
||||
./build.sh web5-dwn
|
||||
```
|
||||
|
||||
## Running Apps via Archipelago
|
||||
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
|
||||
|
||||
- **Router**: http://localhost:18084
|
||||
- **DID Wallet**: http://localhost:18083
|
||||
- **Web5 DWN**: http://localhost:13000
|
||||
- **Nostr RS Relay**: http://localhost:18081
|
||||
- **Strfry**: http://localhost:18082
|
||||
|
||||
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### For Custom Apps (router, did-wallet, web5-dwn)
|
||||
### For Custom Apps (router, did-wallet)
|
||||
|
||||
1. **Make changes** to source code in `apps/<app-id>/src/`
|
||||
2. **Rebuild** the container:
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
||||
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||
- **btcpay-server** — Payment processor (v1.13.5)
|
||||
- **thunderhub** — Lightning management UI (v0.13.31)
|
||||
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||
- **electrumx** — Electrum server
|
||||
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||
@@ -18,12 +17,11 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
||||
- **nostrudel** — Nostr web client (v0.40.0)
|
||||
|
||||
### Web5 & Identity
|
||||
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
||||
- **did-wallet** — Web5 DID Wallet
|
||||
|
||||
### Self-Hosted Services
|
||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
||||
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
|
||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ app:
|
||||
endpoint: http://localhost:32838
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
|
||||
@@ -26,10 +26,19 @@ app:
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -37,6 +46,8 @@ app:
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
|
||||
secret_file: bitcoin-rpc-txrelay-rpcauth
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -28,11 +28,17 @@ app:
|
||||
fi;
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -40,6 +46,8 @@ app:
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
|
||||
secret_file: bitcoin-rpc-txrelay-rpcauth
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
app:
|
||||
id: botfights
|
||||
name: BotFights
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
|
||||
category: community
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/botfights:1.1.0
|
||||
image: 146.59.87.168:3000/lfg2025/botfights:1.1.0
|
||||
pull_policy: always
|
||||
|
||||
dependencies:
|
||||
@@ -62,6 +62,8 @@ app:
|
||||
|
||||
metadata:
|
||||
author: Dorian
|
||||
repo: https://botfights.net
|
||||
icon: /assets/img/app-icons/botfights.svg
|
||||
license: MIT
|
||||
tags:
|
||||
- bitcoin
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.13.7
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
@@ -60,8 +60,8 @@ app:
|
||||
endpoint: http://localhost:49392
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
@@ -79,3 +79,7 @@ app:
|
||||
port: 23000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
|
||||
@@ -10,8 +10,6 @@ app:
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- app_id: web5-dwn
|
||||
version: ">=1.0.0"
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
@@ -40,7 +38,6 @@ app:
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- DWN_ENDPOINT=http://web5-dwn:3000
|
||||
- WALLET_STORAGE=/app/wallet
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`DID Wallet listening on port ${port}`);
|
||||
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
|
||||
image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
@@ -25,7 +25,7 @@ app:
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
memory_limit: 6Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
@@ -48,15 +48,30 @@ app:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=3072
|
||||
- CACHE_MB=1024
|
||||
- MAX_SEND=10000000
|
||||
|
||||
# The ElectrumX dashboard tile is served by the host-networked companion UI
|
||||
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
|
||||
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
|
||||
# so the tile resolves a launch URL without relying on the hand-maintained
|
||||
# override in appSessionConfig.ts (which the generator can clobber). The
|
||||
# backend only validates this block — it does not proxy/health-check it.
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: ElectrumX server status and connection details
|
||||
type: ui
|
||||
port: 50002
|
||||
protocol: http
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10m
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
|
||||
@@ -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
|
||||
@@ -5,9 +5,17 @@ app:
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
|
||||
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- |-
|
||||
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
|
||||
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
|
||||
sleep 30;
|
||||
done;
|
||||
exec fedimintd
|
||||
derived_env:
|
||||
- key: FM_P2P_URL
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
@@ -40,7 +48,9 @@ app:
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp
|
||||
- host: 8175
|
||||
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
|
||||
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
|
||||
@@ -52,7 +62,7 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://host.archipelago:8332
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
@@ -67,6 +77,15 @@ app:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Guardian UI
|
||||
description: Fedimint Guardian wait/proxy UI
|
||||
type: ui
|
||||
port: 8175
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
@@ -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
|
||||
+81
-46
@@ -1,52 +1,87 @@
|
||||
id: gitea
|
||||
name: Gitea
|
||||
version: "1.23"
|
||||
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
|
||||
category: development
|
||||
icon: git-branch
|
||||
port: 3000
|
||||
internal_port: 3001
|
||||
ssh_port: 2222
|
||||
image: docker.io/gitea/gitea:1.23
|
||||
tier: optional
|
||||
app:
|
||||
id: gitea
|
||||
name: Gitea
|
||||
version: "1.23"
|
||||
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
|
||||
category: development
|
||||
|
||||
requires:
|
||||
memory_mb: 256
|
||||
disk_mb: 500
|
||||
container:
|
||||
image: docker.io/gitea/gitea:1.23
|
||||
pull_policy: if-not-present
|
||||
|
||||
volumes:
|
||||
- host: /var/lib/archipelago/gitea/data
|
||||
container: /data
|
||||
- host: /var/lib/archipelago/gitea/config
|
||||
container: /etc/gitea
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
|
||||
environment:
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
GITEA__server__SSH_LISTEN_PORT: "22"
|
||||
GITEA__server__LFS_START_SERVER: "true"
|
||||
GITEA__packages__ENABLED: "true"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 500Mi
|
||||
|
||||
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
|
||||
# new tab on host port 3001 instead of embedding it in an iframe.
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: "http://127.0.0.1:3001"
|
||||
extra_headers:
|
||||
- "proxy_hide_header X-Frame-Options"
|
||||
- "proxy_hide_header Content-Security-Policy"
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: false
|
||||
network_policy: bridge
|
||||
|
||||
health_check:
|
||||
endpoint: /
|
||||
interval: 120
|
||||
timeout: 5
|
||||
retries: 3
|
||||
ports:
|
||||
- host: 3001
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
- host: 2222
|
||||
container: 22
|
||||
protocol: tcp
|
||||
|
||||
features:
|
||||
- Git repositories with web UI
|
||||
- Built-in container/package registry
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- Lightweight (SQLite, no external DB needed)
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/gitea/data
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/gitea/config
|
||||
target: /etc/gitea
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__server__LFS_START_SERVER=true
|
||||
- GITEA__packages__ENABLED=true
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3000
|
||||
path: /
|
||||
interval: 120s
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Gitea web interface
|
||||
type: ui
|
||||
port: 3001
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/gitea.svg
|
||||
repo: https://gitea.com
|
||||
tier: optional
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
features:
|
||||
- Git repositories with web UI
|
||||
- Built-in container/package registry
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- Lightweight SQLite deployment
|
||||
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: http://127.0.0.1:3001
|
||||
extra_headers:
|
||||
- proxy_hide_header X-Frame-Options
|
||||
- proxy_hide_header Content-Security-Policy
|
||||
|
||||
@@ -49,5 +49,9 @@ app:
|
||||
endpoint: http://localhost:3000
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
app:
|
||||
id: home-assistant
|
||||
id: homeassistant
|
||||
name: Home Assistant
|
||||
version: 2024.1.0
|
||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||
|
||||
container:
|
||||
image: homeassistant/home-assistant:2024.1
|
||||
image_signature: cosign://...
|
||||
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW]
|
||||
readonly_root: false # Home Assistant needs write access
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: host # Requires host network for device discovery
|
||||
network_policy: isolated
|
||||
apparmor_profile: home-assistant
|
||||
|
||||
ports:
|
||||
@@ -36,24 +36,32 @@ app:
|
||||
source: /var/lib/archipelago/home-assistant
|
||||
target: /config
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/run/dbus
|
||||
target: /var/run/dbus
|
||||
options: [ro]
|
||||
|
||||
devices:
|
||||
- /dev/ttyUSB0 # Serial devices
|
||||
- /dev/ttyACM0 # USB devices
|
||||
devices: []
|
||||
|
||||
environment:
|
||||
- TZ=UTC
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8123
|
||||
path: /
|
||||
type: tcp
|
||||
endpoint: localhost:8123
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Home Assistant dashboard
|
||||
type: ui
|
||||
port: 8123
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/homeassistant.png
|
||||
category: home
|
||||
author: Home Assistant
|
||||
repo: https://github.com/home-assistant/core
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
app:
|
||||
id: indeedhub
|
||||
name: Indeehub
|
||||
version: 0.1.0
|
||||
name: IndeeHub
|
||||
version: 1.0.0
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||
category: media
|
||||
category: community
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
network: indeedhub-net
|
||||
|
||||
@@ -70,8 +70,9 @@ app:
|
||||
|
||||
metadata:
|
||||
author: Indeehub Team
|
||||
icon: /assets/img/app-icons/indeedhub.png
|
||||
website: https://indeedhub.com
|
||||
source: https://github.com/indeedhub/indeedhub
|
||||
repo: https://github.com/indeedhub/indeedhub
|
||||
license: MIT
|
||||
tags:
|
||||
- bitcoin
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
app:
|
||||
id: jellyfin
|
||||
name: Jellyfin
|
||||
version: 10.8.13
|
||||
description: Free media server. Stream movies, music, and photos.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8096
|
||||
container: 8096
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/jellyfin/config
|
||||
target: /config
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/jellyfin/cache
|
||||
target: /cache
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8096
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Jellyfin media dashboard
|
||||
type: ui
|
||||
port: 8096
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/jellyfin.webp
|
||||
category: data
|
||||
author: Jellyfin
|
||||
repo: https://github.com/jellyfin/jellyfin
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: lnd
|
||||
name: Lightning Network Daemon
|
||||
name: LND
|
||||
version: 0.18.4
|
||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
|
||||
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool
|
||||
version: 2.5.0
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: mempool/mempool:v2.5.0
|
||||
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
app:
|
||||
id: meshtastic
|
||||
name: Meshtastic
|
||||
version: 2.5.0
|
||||
version: 2-daily-alpine
|
||||
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
||||
|
||||
container:
|
||||
image: meshtastic/meshtasticd:2.5.6
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
image: docker.io/meshtastic/meshtasticd:daily-alpine
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -29,33 +28,42 @@ app:
|
||||
ports:
|
||||
- host: 4403
|
||||
container: 4403
|
||||
protocol: tcp # HTTP API
|
||||
- host: 1883
|
||||
container: 1883
|
||||
protocol: tcp # MQTT (optional)
|
||||
protocol: tcp # Meshtastic TCP API
|
||||
|
||||
devices:
|
||||
- /dev/ttyUSB0 # LoRa radio device (if connected)
|
||||
- /dev/ttyACM0 # Alternative device path
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/meshtastic
|
||||
target: /app/data
|
||||
target: /var/lib/meshtasticd
|
||||
options: [rw]
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/meshtastic/config.yaml
|
||||
content: |
|
||||
General:
|
||||
MACAddress: AA:BB:CC:DD:EE:01
|
||||
Webserver:
|
||||
Port: 4403
|
||||
|
||||
environment:
|
||||
- MESHTASTIC_PORT=/dev/ttyUSB0
|
||||
- MESHTASTIC_SERIAL=true
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:4403
|
||||
path: /health
|
||||
type: cmd
|
||||
endpoint: test -f /var/lib/meshtasticd/config.yaml
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
networking:
|
||||
mesh_enabled: true
|
||||
local_network_access: true
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/meshcore.svg
|
||||
category: networking
|
||||
tier: recommended
|
||||
repo: https://github.com/meshtastic/firmware
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
app:
|
||||
id: nextcloud
|
||||
name: Nextcloud
|
||||
version: "29"
|
||||
description: Your own private cloud. File sync, calendars, contacts.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/nextcloud:29
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8085
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nextcloud
|
||||
target: /var/www/html
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:80
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Nextcloud file and collaboration dashboard
|
||||
type: ui
|
||||
port: 8085
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/nextcloud.webp
|
||||
category: data
|
||||
author: Nextcloud
|
||||
repo: https://github.com/nextcloud/server
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
@@ -28,7 +28,7 @@ app:
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
ports:
|
||||
- host: 8081
|
||||
- host: 18081
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
|
||||
@@ -49,8 +49,8 @@ app:
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# OnlyOffice - uses official image
|
||||
FROM onlyoffice/documentserver:7.5.0
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,50 +0,0 @@
|
||||
app:
|
||||
id: onlyoffice
|
||||
name: OnlyOffice
|
||||
version: 7.5.0
|
||||
description: Office suite and document collaboration. Edit documents, spreadsheets, and presentations.
|
||||
|
||||
container:
|
||||
image: onlyoffice/documentserver:7.5.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false # OnlyOffice needs write access
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: onlyoffice
|
||||
|
||||
ports:
|
||||
- host: 8088
|
||||
container: 80
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/onlyoffice
|
||||
target: /var/www/onlyoffice/Data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- JWT_ENABLED=false
|
||||
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8088
|
||||
path: /healthcheck
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,60 @@
|
||||
app:
|
||||
id: photoprism
|
||||
name: PhotoPrism
|
||||
version: "240915"
|
||||
description: AI-powered photo management with facial recognition.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/photoprism:240915
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 2342
|
||||
container: 2342
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/photoprism
|
||||
target: /photoprism/storage
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PHOTOPRISM_ADMIN_PASSWORD=archipelago
|
||||
- PHOTOPRISM_DEFAULT_LOCALE=en
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:2342
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: PhotoPrism photo library
|
||||
type: ui
|
||||
port: 2342
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/photoprism.svg
|
||||
category: data
|
||||
author: PhotoPrism
|
||||
repo: https://github.com/photoprism/photoprism
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
@@ -0,0 +1,64 @@
|
||||
app:
|
||||
id: portainer
|
||||
name: Portainer
|
||||
version: 2.19.4
|
||||
description: Container management web UI for the local Podman socket.
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||
pull_policy: if-not-present
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 9000
|
||||
container: 9000
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/portainer
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/portainer/compose
|
||||
target: /data/compose
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /run/user/1000/podman/podman.sock
|
||||
target: /var/run/docker.sock
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Portainer web interface
|
||||
type: ui
|
||||
port: 9000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/portainer.webp
|
||||
tier: optional
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
features:
|
||||
- Container management dashboard
|
||||
- Local Podman socket access
|
||||
- Compose stack storage
|
||||
@@ -45,5 +45,5 @@ app:
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
timeout: 30s
|
||||
retries: 5
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
app:
|
||||
id: uptime-kuma
|
||||
name: Uptime Kuma
|
||||
version: 1.23.0
|
||||
description: Self-hosted uptime monitoring.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/uptime-kuma:1
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
custom_args: ["--", "node", "server/server.js"]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 3002
|
||||
container: 3001
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/uptime-kuma
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- TZ=UTC
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: localhost:3001
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/uptime-kuma.webp
|
||||
category: data
|
||||
tier: recommended
|
||||
author: Uptime Kuma
|
||||
repo: https://github.com/louislam/uptime-kuma
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
@@ -0,0 +1,60 @@
|
||||
app:
|
||||
id: vaultwarden
|
||||
name: Vaultwarden
|
||||
version: 1.30.0
|
||||
description: Self-hosted password vault with zero-knowledge encryption.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8082
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/vaultwarden
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:80
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Vaultwarden web vault
|
||||
type: ui
|
||||
port: 8082
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/vaultwarden.webp
|
||||
category: data
|
||||
tier: recommended
|
||||
author: Vaultwarden
|
||||
repo: https://github.com/dani-garcia/vaultwarden
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,38 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV DWN_STORAGE_PATH=/app/data
|
||||
ENV DID_METHOD=key
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Web5 DWN (Decentralized Web Node)
|
||||
|
||||
Personal data store for Web5. Store and sync your decentralized data across devices.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# From the apps directory
|
||||
./build.sh web5-dwn
|
||||
|
||||
# Or manually
|
||||
cd web5-dwn
|
||||
docker build -t archipelago/web5-dwn:latest .
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd web5-dwn
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
- **3000**: HTTP API (dev: 13000)
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-v /tmp/archipelago-dev/web5-dwn:/app/data \
|
||||
-e DWN_STORAGE_PATH=/app/data \
|
||||
archipelago/web5-dwn:latest
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
app:
|
||||
id: web5-dwn
|
||||
name: Decentralized Web Node
|
||||
version: 1.0.0
|
||||
description: Personal data store for Web5. Store and sync your decentralized data across devices.
|
||||
|
||||
container:
|
||||
image: archipelago/web5-dwn:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 5Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: web5-dwn
|
||||
|
||||
ports:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # HTTP API
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/web5-dwn
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- DWN_STORAGE_PATH=/app/data
|
||||
- DID_METHOD=key
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3000
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web5_integration:
|
||||
did_support: true
|
||||
dwn_protocol: true
|
||||
sync_enabled: true
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "web5-dwn",
|
||||
"version": "1.0.0",
|
||||
"description": "Decentralized Web Node for Web5",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"@web5/api": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'web5-dwn' });
|
||||
});
|
||||
|
||||
// DWN API endpoints
|
||||
app.post('/dwn', async (req, res) => {
|
||||
// Placeholder for DWN protocol implementation
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'DWN protocol endpoint (placeholder)'
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/dwn', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'DWN query endpoint (placeholder)'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Web5 DWN listening on port ${port}`);
|
||||
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+3077
-91
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.67-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())
|
||||
{
|
||||
@@ -128,6 +131,22 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(handled) =
|
||||
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
|
||||
std::path::Path::new("/var/lib/archipelago"),
|
||||
from,
|
||||
incoming.from_name.as_deref(),
|
||||
&val,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let safe_from = sanitize_log_string(from);
|
||||
@@ -136,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()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod handler;
|
||||
mod rpc;
|
||||
pub(crate) mod rpc;
|
||||
|
||||
pub use handler::ApiHandler;
|
||||
|
||||
@@ -189,6 +189,27 @@ impl RpcHandler {
|
||||
.map(|f| f as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let latest = self.metrics_store.latest().await;
|
||||
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest
|
||||
.map(|s| {
|
||||
let mem_total = s.system.mem_total_bytes as f64;
|
||||
let disk_total = s.system.disk_total_bytes as f64;
|
||||
(
|
||||
s.system.cpu_percent,
|
||||
if mem_total > 0.0 {
|
||||
(s.system.mem_used_bytes as f64 / mem_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
if disk_total > 0.0 {
|
||||
(s.system.disk_used_bytes as f64 / disk_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0, 0.0));
|
||||
|
||||
// Recent alerts from metrics store
|
||||
let recent_alerts: Vec<serde_json::Value> = self
|
||||
.metrics_store
|
||||
@@ -206,10 +227,16 @@ impl RpcHandler {
|
||||
|
||||
let report = serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
|
||||
"hostname": system_hostname().await,
|
||||
"server_url": local_server_url(&self.config.host_ip),
|
||||
"version": data.server_info.version,
|
||||
"uptime_secs": uptime_secs,
|
||||
"cpu_cores": cpu_cores,
|
||||
"ram_mb": total_ram_mb,
|
||||
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
|
||||
"mem_pct": (mem_pct * 10.0).round() / 10.0,
|
||||
"disk_pct": (disk_pct * 10.0).round() / 10.0,
|
||||
"containers": containers,
|
||||
"container_count": data.package_data.len(),
|
||||
"running_count": data.package_data.values()
|
||||
@@ -483,3 +510,24 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_hostname() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!hostname.is_empty()).then_some(hostname)
|
||||
}
|
||||
|
||||
fn local_server_url(host_ip: &str) -> Option<String> {
|
||||
let host_ip = host_ip.trim();
|
||||
if host_ip.is_empty() || host_ip == "127.0.0.1" {
|
||||
None
|
||||
} else {
|
||||
Some(format!("https://{host_ip}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -79,7 +92,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
self.auth_manager
|
||||
let outcome = self
|
||||
.auth_manager
|
||||
.change_password(current_password, new_password, also_change_ssh)
|
||||
.await?;
|
||||
|
||||
@@ -88,7 +102,12 @@ impl RpcHandler {
|
||||
self.session_store.invalidate_all_except(token).await;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"session_rotated": true,
|
||||
"ssh_updated": outcome.ssh_updated,
|
||||
"ssh_error": outcome.ssh_error,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
use super::RpcHandler;
|
||||
use crate::container::docker_packages;
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::{bitcoin_status, identity, peers};
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::ContainerState;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const RELAY_DIR: &str = "bitcoin-relay";
|
||||
const RELAY_STATE_FILE: &str = "state.json";
|
||||
const TXRELAY_USER: &str = "txrelay";
|
||||
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
|
||||
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
|
||||
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelayState {
|
||||
settings: BitcoinRelaySettings,
|
||||
requests: Vec<BitcoinRelayRequest>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelayState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: BitcoinRelaySettings::default(),
|
||||
requests: Vec::new(),
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BitcoinRelaySettings {
|
||||
enabled_for_peers: bool,
|
||||
allow_peer_requests: bool,
|
||||
allow_http: bool,
|
||||
allow_https: bool,
|
||||
allow_tor: bool,
|
||||
selected_peer_pubkey: Option<String>,
|
||||
http_endpoint: Option<String>,
|
||||
https_endpoint: Option<String>,
|
||||
tor_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinRelaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled_for_peers: false,
|
||||
allow_peer_requests: false,
|
||||
allow_http: false,
|
||||
allow_https: true,
|
||||
allow_tor: false,
|
||||
selected_peer_pubkey: None,
|
||||
http_endpoint: None,
|
||||
https_endpoint: None,
|
||||
tor_endpoint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BitcoinRelayRequest {
|
||||
id: String,
|
||||
direction: RelayRequestDirection,
|
||||
status: RelayRequestStatus,
|
||||
peer_pubkey: String,
|
||||
peer_onion: String,
|
||||
peer_name: Option<String>,
|
||||
message: Option<String>,
|
||||
approved_endpoint: Option<String>,
|
||||
credential_secret_path: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestDirection {
|
||||
Incoming,
|
||||
Outbound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RelayRequestStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TrustedRelayPeer {
|
||||
pubkey: String,
|
||||
onion: String,
|
||||
name: Option<String>,
|
||||
relay_approved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TxRelayCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
|
||||
let local_node = local_sync_status().await;
|
||||
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
|
||||
|
||||
Ok(json!({
|
||||
"settings": state.settings,
|
||||
"trusted_nodes": trusted_nodes,
|
||||
"requests": state.requests,
|
||||
"local_node": local_node,
|
||||
"credentials": credential_status,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_update_settings(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
update_bool(
|
||||
¶ms,
|
||||
"enabled_for_peers",
|
||||
&mut state.settings.enabled_for_peers,
|
||||
);
|
||||
update_bool(
|
||||
¶ms,
|
||||
"allow_peer_requests",
|
||||
&mut state.settings.allow_peer_requests,
|
||||
);
|
||||
update_bool(¶ms, "allow_http", &mut state.settings.allow_http);
|
||||
update_bool(¶ms, "allow_https", &mut state.settings.allow_https);
|
||||
update_bool(¶ms, "allow_tor", &mut state.settings.allow_tor);
|
||||
|
||||
update_endpoint(¶ms, "http_endpoint", &mut state.settings.http_endpoint)?;
|
||||
update_endpoint(
|
||||
¶ms,
|
||||
"https_endpoint",
|
||||
&mut state.settings.https_endpoint,
|
||||
)?;
|
||||
update_endpoint(¶ms, "tor_endpoint", &mut state.settings.tor_endpoint)?;
|
||||
|
||||
if state.settings.enabled_for_peers {
|
||||
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
|
||||
ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if !credentials_were_ready {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
}
|
||||
|
||||
if params.get("selected_peer_pubkey").is_some() {
|
||||
let selected = params
|
||||
.get("selected_peer_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pubkey) = selected {
|
||||
if !known_peers.iter().any(|p| p.pubkey == pubkey) {
|
||||
anyhow::bail!("Selected relay peer is not in trusted nodes");
|
||||
}
|
||||
state.settings.selected_peer_pubkey = Some(pubkey.to_string());
|
||||
} else {
|
||||
state.settings.selected_peer_pubkey = None;
|
||||
}
|
||||
}
|
||||
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
self.notify(
|
||||
"Bitcoin relay settings updated",
|
||||
"Transaction relay sharing preferences were saved.",
|
||||
)
|
||||
.await;
|
||||
self.handle_bitcoin_relay_status().await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_request_peer(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let peer_pubkey = params
|
||||
.get("peer_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: peer_pubkey"))?;
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(sanitize_optional_text)
|
||||
.transpose()?;
|
||||
let peer = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|p| p.pubkey == peer_pubkey)
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer is not in trusted nodes"))?;
|
||||
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let existing = state.requests.iter_mut().find(|r| {
|
||||
r.direction == RelayRequestDirection::Outbound
|
||||
&& r.peer_pubkey == peer.pubkey
|
||||
&& r.status == RelayRequestStatus::Pending
|
||||
});
|
||||
let request_id = if let Some(req) = existing {
|
||||
req.message = message.clone();
|
||||
req.updated_at = now();
|
||||
req.id.clone()
|
||||
} else {
|
||||
let timestamp = now();
|
||||
let req = BitcoinRelayRequest {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
direction: RelayRequestDirection::Outbound,
|
||||
status: RelayRequestStatus::Pending,
|
||||
peer_pubkey: peer.pubkey.clone(),
|
||||
peer_onion: peer.onion.clone(),
|
||||
peer_name: peer.name.clone(),
|
||||
message: message.clone(),
|
||||
approved_endpoint: None,
|
||||
credential_secret_path: None,
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
let id = req.id.clone();
|
||||
state.requests.push(req);
|
||||
id
|
||||
};
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
|
||||
if let Err(e) = self
|
||||
.send_relay_peer_message(
|
||||
&peer,
|
||||
json!({
|
||||
"type": "bitcoin_relay_request",
|
||||
"request_id": request_id,
|
||||
"message": message,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay request");
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"Bitcoin relay request sent",
|
||||
"A trusted peer was asked to approve transaction relay access.",
|
||||
)
|
||||
.await;
|
||||
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_approve_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.update_relay_request_status(params, RelayRequestStatus::Approved)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_reject_request(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.update_relay_request_status(params, RelayRequestStatus::Rejected)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_relay_create_tor_service(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = json!({
|
||||
"name": "bitcoin-rpc",
|
||||
"local_port": 80,
|
||||
"remote_port": 80,
|
||||
});
|
||||
let created = match self.handle_tor_create_service(Some(params)).await {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.to_string().contains("already exists") => {
|
||||
self.handle_tor_get_onion_address(Some(json!({ "name": "bitcoin-rpc" })))
|
||||
.await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let onion = created
|
||||
.get("onion_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(onion) = onion {
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
state.settings.allow_tor = true;
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
}
|
||||
|
||||
self.notify(
|
||||
"Bitcoin relay Tor service enabled",
|
||||
"A Tor endpoint was created for Bitcoin transaction relay access.",
|
||||
)
|
||||
.await;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_relay_request_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
status: RelayRequestStatus,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let request_id = params
|
||||
.get("id")
|
||||
.or_else(|| params.get("request_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||
let serving_endpoint = if status == RelayRequestStatus::Approved {
|
||||
preferred_endpoint(&state.settings)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request_direction = state
|
||||
.requests
|
||||
.iter()
|
||||
.find(|r| r.id == request_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||
.direction;
|
||||
if status == RelayRequestStatus::Approved
|
||||
&& request_direction == RelayRequestDirection::Incoming
|
||||
&& serving_endpoint.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Configure an HTTP, HTTPS, or Tor relay endpoint before approving access"
|
||||
);
|
||||
}
|
||||
let credentials = if status == RelayRequestStatus::Approved {
|
||||
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||
if request_direction == RelayRequestDirection::Incoming {
|
||||
self.restart_bitcoin_backends_for_txrelay().await;
|
||||
}
|
||||
Some(credentials)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (peer_pubkey, peer_onion, peer_name, direction) = {
|
||||
let req = state
|
||||
.requests
|
||||
.iter_mut()
|
||||
.find(|r| r.id == request_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
|
||||
req.status = status;
|
||||
req.updated_at = now();
|
||||
if let Some(endpoint) = &serving_endpoint {
|
||||
req.approved_endpoint = Some(endpoint.clone());
|
||||
}
|
||||
(
|
||||
req.peer_pubkey.clone(),
|
||||
req.peer_onion.clone(),
|
||||
req.peer_name.clone(),
|
||||
req.direction,
|
||||
)
|
||||
};
|
||||
let peer = peers::load_peers(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|p| p.pubkey == peer_pubkey);
|
||||
let peer_name = peer_name.unwrap_or_else(|| peer_onion.clone());
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(&self.config.data_dir, &state).await?;
|
||||
|
||||
if let Some(peer) = peer {
|
||||
let message_type = match status {
|
||||
RelayRequestStatus::Approved => "bitcoin_relay_approved",
|
||||
RelayRequestStatus::Rejected => "bitcoin_relay_rejected",
|
||||
RelayRequestStatus::Pending => "bitcoin_relay_pending",
|
||||
};
|
||||
if let Err(e) = self
|
||||
.send_relay_peer_message(
|
||||
&peer,
|
||||
relay_response_payload(
|
||||
message_type,
|
||||
request_id,
|
||||
direction,
|
||||
serving_endpoint.as_deref(),
|
||||
credentials.as_ref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay response");
|
||||
}
|
||||
}
|
||||
|
||||
let title = match status {
|
||||
RelayRequestStatus::Approved => "Bitcoin relay request approved",
|
||||
RelayRequestStatus::Rejected => "Bitcoin relay request rejected",
|
||||
RelayRequestStatus::Pending => "Bitcoin relay request updated",
|
||||
};
|
||||
self.notify(
|
||||
title,
|
||||
&format!("Relay access request for {peer_name} was updated."),
|
||||
)
|
||||
.await;
|
||||
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||
}
|
||||
|
||||
async fn send_relay_peer_message(
|
||||
&self,
|
||||
peer: &peers::KnownPeer,
|
||||
mut payload: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let my_pubkey = data.server_info.pubkey.clone();
|
||||
let my_did = identity::did_key_from_pubkey_hex(&my_pubkey).ok();
|
||||
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
payload["from_did"] = my_did.map(serde_json::Value::String).unwrap_or_default();
|
||||
payload["from_pubkey"] = serde_json::Value::String(my_pubkey.clone());
|
||||
payload["from_onion"] = serde_json::Value::String(my_onion);
|
||||
payload["from_name"] = data
|
||||
.server_info
|
||||
.name
|
||||
.clone()
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or_default();
|
||||
|
||||
let to_fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, &peer.onion).await;
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||
.await
|
||||
.ok();
|
||||
crate::node_message::send_to_peer(
|
||||
&peer.onion,
|
||||
to_fips_npub.as_deref(),
|
||||
&my_pubkey,
|
||||
&payload.to_string(),
|
||||
signing_key.as_ref().map(|i| i.signing_key()),
|
||||
Some(&peer.pubkey),
|
||||
data.server_info.name.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn notify(&self, title: &str, message: &str) {
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.notifications.push(Notification {
|
||||
id: format!("bitcoin-relay-{}", uuid::Uuid::new_v4()),
|
||||
level: NotificationLevel::Info,
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
timestamp: now(),
|
||||
app_id: Some("bitcoin-knots".to_string()),
|
||||
});
|
||||
let len = data.notifications.len();
|
||||
if len > 30 {
|
||||
data.notifications.drain(0..len - 30);
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
async fn restart_bitcoin_backends_for_txrelay(&self) {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
|
||||
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
for app_id in ["bitcoin-knots", "bitcoin-core"] {
|
||||
let Ok(status) = orchestrator.status(app_id).await else {
|
||||
continue;
|
||||
};
|
||||
if status.state != ContainerState::Running {
|
||||
continue;
|
||||
}
|
||||
match orchestrator.restart(app_id).await {
|
||||
Ok(()) => tracing::info!(
|
||||
app_id,
|
||||
"Restarted Bitcoin backend to load txrelay RPC credentials"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
app_id,
|
||||
error = %e,
|
||||
"Failed to restart Bitcoin backend after txrelay credential update"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_incoming_relay_message(
|
||||
data_dir: &Path,
|
||||
from_pubkey: &str,
|
||||
from_name: Option<&str>,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<Option<&'static str>> {
|
||||
let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match msg_type {
|
||||
"bitcoin_relay_request" => {
|
||||
let from_onion = payload
|
||||
.get("from_onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let message = payload
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(sanitize_optional_text)
|
||||
.transpose()?;
|
||||
let remote_request_id = payload
|
||||
.get("request_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let mut state = load_relay_state(data_dir).await?;
|
||||
if !state.settings.allow_peer_requests {
|
||||
return Ok(Some("bitcoin_relay_request_disabled"));
|
||||
}
|
||||
if !state.requests.iter().any(|r| {
|
||||
r.direction == RelayRequestDirection::Incoming
|
||||
&& r.peer_pubkey == from_pubkey
|
||||
&& r.status == RelayRequestStatus::Pending
|
||||
}) {
|
||||
let timestamp = now();
|
||||
state.requests.push(BitcoinRelayRequest {
|
||||
id: if remote_request_id.is_empty() {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
} else {
|
||||
remote_request_id.to_string()
|
||||
},
|
||||
direction: RelayRequestDirection::Incoming,
|
||||
status: RelayRequestStatus::Pending,
|
||||
peer_pubkey: from_pubkey.to_string(),
|
||||
peer_onion: from_onion,
|
||||
peer_name: from_name.map(String::from),
|
||||
message,
|
||||
approved_endpoint: None,
|
||||
credential_secret_path: None,
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
});
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(Some("bitcoin_relay_request"))
|
||||
}
|
||||
"bitcoin_relay_approved" | "bitcoin_relay_rejected" => {
|
||||
let request_id = payload.get("request_id").and_then(|v| v.as_str());
|
||||
let mut state = load_relay_state(data_dir).await?;
|
||||
let status = if msg_type == "bitcoin_relay_approved" {
|
||||
RelayRequestStatus::Approved
|
||||
} else {
|
||||
RelayRequestStatus::Rejected
|
||||
};
|
||||
let approved_access = if status == RelayRequestStatus::Approved {
|
||||
save_peer_relay_access(data_dir, from_pubkey, payload).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(req) = state.requests.iter_mut().find(|r| {
|
||||
r.direction == RelayRequestDirection::Outbound
|
||||
&& r.peer_pubkey == from_pubkey
|
||||
&& request_id.map(|id| id == r.id).unwrap_or(true)
|
||||
}) {
|
||||
req.status = status;
|
||||
req.updated_at = now();
|
||||
if let Some((endpoint, secret_path)) = approved_access {
|
||||
req.approved_endpoint = Some(endpoint);
|
||||
req.credential_secret_path = Some(secret_path);
|
||||
}
|
||||
state.updated_at = Some(now());
|
||||
save_relay_state(data_dir, &state).await?;
|
||||
}
|
||||
Ok(Some(if msg_type == "bitcoin_relay_approved" {
|
||||
"bitcoin_relay_approved"
|
||||
} else {
|
||||
"bitcoin_relay_rejected"
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_relay_peers(
|
||||
known_peers: &[peers::KnownPeer],
|
||||
state: &BitcoinRelayState,
|
||||
) -> Vec<TrustedRelayPeer> {
|
||||
known_peers
|
||||
.iter()
|
||||
.map(|peer| TrustedRelayPeer {
|
||||
pubkey: peer.pubkey.clone(),
|
||||
onion: peer.onion.clone(),
|
||||
name: peer.name.clone(),
|
||||
relay_approved: state.requests.iter().any(|req| {
|
||||
req.peer_pubkey == peer.pubkey && req.status == RelayRequestStatus::Approved
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
|
||||
let credentials_available = txrelay_credentials_available(data_dir).await;
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password_available = fs::metadata(&password_path).await.is_ok();
|
||||
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
|
||||
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
|
||||
json!({
|
||||
"username": TXRELAY_USER,
|
||||
"available": credentials_available,
|
||||
"password_available": password_available,
|
||||
"rpcauth_available": rpcauth_available,
|
||||
"client_env_available": client_env_available,
|
||||
"client_env_path": client_env_path.display().to_string(),
|
||||
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
|
||||
})
|
||||
}
|
||||
|
||||
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
fs::metadata(&password_path).await.is_ok()
|
||||
&& fs::metadata(&rpcauth_path).await.is_ok()
|
||||
&& fs::metadata(&client_env_path).await.is_ok()
|
||||
}
|
||||
|
||||
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
|
||||
/// the random password, its derived `rpcauth` line, and the client env file.
|
||||
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
|
||||
/// required `secret_env`, so this must run before bitcoind starts — otherwise
|
||||
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
|
||||
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
|
||||
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||
let password = match read_trimmed(&password_path).await {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let generated = generate_random_password();
|
||||
write_secret_file(&password_path, &generated).await?;
|
||||
generated
|
||||
}
|
||||
};
|
||||
let rpcauth = match read_trimmed(&rpcauth_path).await {
|
||||
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
|
||||
_ => {
|
||||
let generated = generate_rpcauth(TXRELAY_USER, &password);
|
||||
write_secret_file(&rpcauth_path, &generated).await?;
|
||||
generated
|
||||
}
|
||||
};
|
||||
let client_env = format!(
|
||||
"BITCOIN_RPC_TXRELAY_USER={}\nBITCOIN_RPC_TXRELAY_PASSWORD={}\nBITCOIN_RPC_TXRELAY_RPCAUTH={}\n",
|
||||
TXRELAY_USER, password, rpcauth
|
||||
);
|
||||
write_secret_file(&client_env_path, &client_env).await?;
|
||||
|
||||
Ok(TxRelayCredentials {
|
||||
username: TXRELAY_USER.to_string(),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
fn txrelay_secret_paths(data_dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let secrets_dir = data_dir.join("secrets");
|
||||
(
|
||||
secrets_dir.join(TXRELAY_PASSWORD_FILE),
|
||||
secrets_dir.join(TXRELAY_RPCAUTH_FILE),
|
||||
secrets_dir.join(TXRELAY_CLIENT_ENV_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
async fn write_secret_file(path: &Path, contents: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(path, contents).await?;
|
||||
set_private_permissions(path).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_private_permissions(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random_password() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
BASE64.encode(bytes)
|
||||
}
|
||||
|
||||
fn generate_rpcauth(username: &str, password: &str) -> String {
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt_bytes);
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()).expect("HMAC accepts any key length");
|
||||
mac.update(password.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
format!("{username}:{salt_hex}${hash_hex}")
|
||||
}
|
||||
|
||||
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
|
||||
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
|
||||
return false;
|
||||
};
|
||||
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
|
||||
return false;
|
||||
};
|
||||
if salt_hex.is_empty() || expected_hash.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(password.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
hash_hex.eq_ignore_ascii_case(expected_hash)
|
||||
}
|
||||
|
||||
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
|
||||
if settings.allow_https {
|
||||
if let Some(endpoint) = settings.https_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
if settings.allow_tor {
|
||||
if let Some(endpoint) = settings.tor_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
if settings.allow_http {
|
||||
if let Some(endpoint) = settings.http_endpoint.clone() {
|
||||
return Some(endpoint);
|
||||
}
|
||||
}
|
||||
settings
|
||||
.https_endpoint
|
||||
.clone()
|
||||
.or_else(|| settings.tor_endpoint.clone())
|
||||
.or_else(|| settings.http_endpoint.clone())
|
||||
}
|
||||
|
||||
fn relay_response_payload(
|
||||
message_type: &str,
|
||||
request_id: &str,
|
||||
request_direction: RelayRequestDirection,
|
||||
endpoint: Option<&str>,
|
||||
credentials: Option<&TxRelayCredentials>,
|
||||
) -> serde_json::Value {
|
||||
let mut payload = json!({
|
||||
"type": message_type,
|
||||
"request_id": request_id,
|
||||
});
|
||||
if message_type == "bitcoin_relay_approved"
|
||||
&& request_direction == RelayRequestDirection::Incoming
|
||||
{
|
||||
if let (Some(endpoint), Some(credentials)) = (endpoint, credentials) {
|
||||
payload["relay_access"] = json!({
|
||||
"endpoint": endpoint,
|
||||
"username": &credentials.username,
|
||||
"password": &credentials.password,
|
||||
});
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
async fn save_peer_relay_access(
|
||||
data_dir: &Path,
|
||||
from_pubkey: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let Some(access) = payload.get("relay_access") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let endpoint = access
|
||||
.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(validate_endpoint)
|
||||
.transpose()?;
|
||||
let username = access.get("username").and_then(|v| v.as_str());
|
||||
let password = access.get("password").and_then(|v| v.as_str());
|
||||
let (Some(endpoint), Some(username), Some(password)) = (endpoint, username, password) else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_env_value(username)?;
|
||||
validate_env_value(password)?;
|
||||
|
||||
let secret_path = data_dir.join("secrets").join(format!(
|
||||
"bitcoin-relay-peer-{}.env",
|
||||
safe_pubkey_fragment(from_pubkey)
|
||||
));
|
||||
let contents = format!(
|
||||
"BITCOIN_RELAY_PEER_PUBKEY={}\nBITCOIN_RELAY_ENDPOINT={}\nBITCOIN_RELAY_USERNAME={}\nBITCOIN_RELAY_PASSWORD={}\n",
|
||||
from_pubkey, endpoint, username, password
|
||||
);
|
||||
write_secret_file(&secret_path, &contents).await?;
|
||||
Ok(Some((endpoint, secret_path.display().to_string())))
|
||||
}
|
||||
|
||||
fn validate_env_value(value: &str) -> Result<()> {
|
||||
if value.is_empty() || value.len() > 1024 || value.contains('\n') || value.contains('\r') {
|
||||
anyhow::bail!("Invalid relay credential value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_pubkey_fragment(pubkey: &str) -> String {
|
||||
let fragment = pubkey
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.take(24)
|
||||
.collect::<String>();
|
||||
if fragment.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
fragment
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
|
||||
if state.settings.tor_endpoint.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(onion) = docker_packages::read_tor_address("bitcoin-rpc").await {
|
||||
let onion = onion.trim().trim_end_matches('/').to_string();
|
||||
if !onion.is_empty() {
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
let _ = save_relay_state(data_dir, state).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_sync_status() -> serde_json::Value {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let blockchain = status.blockchain_info.as_ref();
|
||||
let blocks = blockchain
|
||||
.and_then(|v| v.get("blocks"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let headers = blockchain
|
||||
.and_then(|v| v.get("headers"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let initial_block_download = blockchain
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let synced =
|
||||
status.ok && headers > 0 && blocks >= headers.saturating_sub(1) && !initial_block_download;
|
||||
|
||||
json!({
|
||||
"synced": synced,
|
||||
"blocks": blocks,
|
||||
"headers": headers,
|
||||
"chain": blockchain
|
||||
.and_then(|v| v.get("chain"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
"status_ok": status.ok,
|
||||
"status_stale": status.stale,
|
||||
"error": status.error,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_relay_state(data_dir: &Path) -> Result<BitcoinRelayState> {
|
||||
let path = state_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(BitcoinRelayState::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn save_relay_state(data_dir: &Path, state: &BitcoinRelayState) -> Result<()> {
|
||||
let dir = data_dir.join(RELAY_DIR);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
let content = serde_json::to_string_pretty(state)?;
|
||||
fs::write(dir.join(RELAY_STATE_FILE), content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn state_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(RELAY_DIR).join(RELAY_STATE_FILE)
|
||||
}
|
||||
|
||||
fn update_bool(params: &serde_json::Value, key: &str, target: &mut bool) {
|
||||
if let Some(value) = params.get(key).and_then(|v| v.as_bool()) {
|
||||
*target = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_endpoint(
|
||||
params: &serde_json::Value,
|
||||
key: &str,
|
||||
target: &mut Option<String>,
|
||||
) -> Result<()> {
|
||||
if !params.get(key).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let endpoint = params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
*target = endpoint.map(validate_endpoint).transpose()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint(endpoint: &str) -> Result<String> {
|
||||
if endpoint.len() > 512 || endpoint.contains('\n') || endpoint.contains('\r') {
|
||||
anyhow::bail!("Invalid endpoint");
|
||||
}
|
||||
let lower = endpoint.to_ascii_lowercase();
|
||||
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
|
||||
anyhow::bail!("Endpoint must start with http:// or https://");
|
||||
}
|
||||
Ok(endpoint.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_optional_text(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.len() > 500 || value.contains('\0') {
|
||||
anyhow::bail!("Invalid message");
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
@@ -4,8 +4,9 @@ use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ORCHESTRATOR_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_container_install(
|
||||
@@ -171,46 +172,69 @@ impl RpcHandler {
|
||||
// between "installed" and "not-installed" in the UI.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
||||
let containers: Vec<serde_json::Value> = data
|
||||
.package_data
|
||||
.iter()
|
||||
.map(|(id, pkg)| {
|
||||
// Keep this mapping in sync with the UI's
|
||||
// ContainerStatus.state union in
|
||||
// neode-ui/src/api/container-client.ts. The UI maps
|
||||
// transitional variants to single-button labels
|
||||
// (Stopping… / Starting… / Restarting…).
|
||||
let state = match &pkg.state {
|
||||
crate::data_model::PackageState::Running => "running",
|
||||
crate::data_model::PackageState::Stopped => "stopped",
|
||||
crate::data_model::PackageState::Exited => "exited",
|
||||
crate::data_model::PackageState::Starting => "starting",
|
||||
crate::data_model::PackageState::Stopping => "stopping",
|
||||
crate::data_model::PackageState::Restarting => "restarting",
|
||||
crate::data_model::PackageState::Installing => "installing",
|
||||
crate::data_model::PackageState::Installed => "installed",
|
||||
crate::data_model::PackageState::Updating => "updating",
|
||||
crate::data_model::PackageState::Removing => "removing",
|
||||
crate::data_model::PackageState::CreatingBackup => "creating-backup",
|
||||
crate::data_model::PackageState::RestoringBackup => "restoring-backup",
|
||||
crate::data_model::PackageState::BackingUp => "backing-up",
|
||||
};
|
||||
let lan = pkg
|
||||
.installed
|
||||
.as_ref()
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref());
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"state": state,
|
||||
"image": "",
|
||||
"created": "",
|
||||
"ports": [],
|
||||
"lan_address": lan,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut containers = Vec::with_capacity(data.package_data.len());
|
||||
for (id, pkg) in &data.package_data {
|
||||
// Keep this mapping in sync with the UI's
|
||||
// ContainerStatus.state union in
|
||||
// neode-ui/src/api/container-client.ts. The UI maps
|
||||
// transitional variants to single-button labels
|
||||
// (Stopping… / Starting… / Restarting…).
|
||||
let mut state = match &pkg.state {
|
||||
crate::data_model::PackageState::Running => "running".to_string(),
|
||||
crate::data_model::PackageState::Stopped => "stopped".to_string(),
|
||||
crate::data_model::PackageState::Exited => "exited".to_string(),
|
||||
crate::data_model::PackageState::Starting => "starting".to_string(),
|
||||
crate::data_model::PackageState::Stopping => "stopping".to_string(),
|
||||
crate::data_model::PackageState::Restarting => "restarting".to_string(),
|
||||
crate::data_model::PackageState::Installing => "installing".to_string(),
|
||||
crate::data_model::PackageState::Installed => "installed".to_string(),
|
||||
crate::data_model::PackageState::Updating => "updating".to_string(),
|
||||
crate::data_model::PackageState::Removing => "removing".to_string(),
|
||||
crate::data_model::PackageState::CreatingBackup => {
|
||||
"creating-backup".to_string()
|
||||
}
|
||||
crate::data_model::PackageState::RestoringBackup => {
|
||||
"restoring-backup".to_string()
|
||||
}
|
||||
crate::data_model::PackageState::BackingUp => "backing-up".to_string(),
|
||||
};
|
||||
|
||||
// Scanner backoff preserves cached package_data. Refresh stable
|
||||
// states so callers do not see stale `running`/`exited` after
|
||||
// health-monitor recovery or Quadlet --rm container removal.
|
||||
if state == "running" && requires_launch_port_for_health(id) {
|
||||
if !self.cached_reachable_health(id).await?.is_some() {
|
||||
state = live_state_for_app(id)
|
||||
.await
|
||||
.unwrap_or("starting".to_string());
|
||||
}
|
||||
} else if should_refresh_cached_state(&state) {
|
||||
if launch_port_reachable(id).await {
|
||||
state = "running".to_string();
|
||||
} else {
|
||||
if let Some(live) = live_state_for_app(id).await {
|
||||
state = live;
|
||||
} else if quadlet_service_active(id).await {
|
||||
state = "starting".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lan = pkg
|
||||
.installed
|
||||
.as_ref()
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref());
|
||||
containers.push(serde_json::json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"state": state,
|
||||
"image": "",
|
||||
"created": "",
|
||||
"ports": [],
|
||||
"lan_address": lan,
|
||||
}));
|
||||
}
|
||||
return Ok(serde_json::json!(containers));
|
||||
}
|
||||
|
||||
@@ -383,15 +407,33 @@ impl RpcHandler {
|
||||
// If app_id is provided, get health for that app.
|
||||
if let Some(params) = params {
|
||||
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
if let Some(health) = self.cached_state_health(app_id).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
if requires_launch_port_for_health(app_id) {
|
||||
return Ok(serde_json::json!({ app_id: "starting" }));
|
||||
}
|
||||
|
||||
if let Some(health) = self.stack_health(app_id).await? {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.health(&candidate).await {
|
||||
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Err(e) => last_err = Some(e),
|
||||
match tokio::time::timeout(
|
||||
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||
orchestrator.health(&candidate),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(health)) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Ok(Err(e)) => last_err = Some(e),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
@@ -424,14 +466,19 @@ impl RpcHandler {
|
||||
.and_then(|s| s.strip_suffix("-dev"))
|
||||
.or_else(|| container.name.strip_prefix("archy-"))
|
||||
.unwrap_or(container.name.as_str());
|
||||
match orchestrator.health(app_id_candidate).await {
|
||||
Ok(health) => {
|
||||
match tokio::time::timeout(
|
||||
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||
orchestrator.health(app_id_candidate),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(health)) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String(health),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
@@ -443,6 +490,65 @@ impl RpcHandler {
|
||||
Ok(serde_json::Value::Object(health_map))
|
||||
}
|
||||
|
||||
async fn cached_state_health(&self, app_id: &str) -> Option<&'static str> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let Some(pkg) = data.package_data.get(app_id) else {
|
||||
if data.server_info.status_info.containers_scanned {
|
||||
return Some("stopped");
|
||||
}
|
||||
return None;
|
||||
};
|
||||
match pkg.state {
|
||||
crate::data_model::PackageState::Running => None,
|
||||
crate::data_model::PackageState::Installing
|
||||
| crate::data_model::PackageState::Installed
|
||||
| crate::data_model::PackageState::Starting => Some("starting"),
|
||||
crate::data_model::PackageState::Stopping
|
||||
| crate::data_model::PackageState::Stopped
|
||||
| crate::data_model::PackageState::Exited => Some("stopped"),
|
||||
crate::data_model::PackageState::Removing => Some("removing"),
|
||||
crate::data_model::PackageState::Restarting
|
||||
| crate::data_model::PackageState::Updating
|
||||
| crate::data_model::PackageState::CreatingBackup
|
||||
| crate::data_model::PackageState::RestoringBackup
|
||||
| crate::data_model::PackageState::BackingUp => Some("starting"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_reachable_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pkg = data.package_data.get(app_id);
|
||||
if matches!(
|
||||
pkg.map(|pkg| &pkg.state),
|
||||
Some(crate::data_model::PackageState::Removing)
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let url = pkg
|
||||
.and_then(|pkg| pkg.installed.as_ref())
|
||||
.and_then(|i| i.interface_addresses.get("main"))
|
||||
.and_then(|a| a.lan_address.as_deref())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| health_probe_url_for_app(app_id));
|
||||
|
||||
let Some(url) = url else {
|
||||
return Ok(None);
|
||||
};
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return Ok(http_launch_url_reachable(&url)
|
||||
.await
|
||||
.then(|| "healthy".to_string()));
|
||||
}
|
||||
|
||||
let Some(port) = port_from_url(&url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(launch_port_reachable_by_port(port)
|
||||
.await
|
||||
.then(|| "healthy".to_string()))
|
||||
}
|
||||
|
||||
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||
let Some(members) = stack_health_members(app_id) else {
|
||||
return Ok(None);
|
||||
@@ -469,8 +575,14 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if saw_unknown {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(Some(health));
|
||||
}
|
||||
Ok(Some("unknown".to_string()))
|
||||
} else if saw_starting {
|
||||
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||
return Ok(Some(health));
|
||||
}
|
||||
Ok(Some("starting".to_string()))
|
||||
} else {
|
||||
Ok(Some("healthy".to_string()))
|
||||
@@ -482,7 +594,9 @@ async fn member_health(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
app_id: &str,
|
||||
) -> Result<String> {
|
||||
if let Ok(health) = orchestrator.health(app_id).await {
|
||||
if let Ok(Ok(health)) =
|
||||
tokio::time::timeout(ORCHESTRATOR_HEALTH_TIMEOUT, orchestrator.health(app_id)).await
|
||||
{
|
||||
return Ok(health);
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
@@ -508,10 +622,8 @@ fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
]),
|
||||
"fedimint" => Some(&["fedimint"]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -583,6 +695,114 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn should_refresh_cached_state(state: &str) -> bool {
|
||||
matches!(state, "exited" | "stopped" | "stopping")
|
||||
}
|
||||
|
||||
async fn live_state_for_app(app_id: &str) -> Option<String> {
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(live) = inspect_container_state_value(&name).await {
|
||||
if let Some(live_state) = live.get("state").and_then(|v| v.as_str()) {
|
||||
return Some(live_state.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn quadlet_service_active(app_id: &str) -> bool {
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
let service = format!("{name}.service");
|
||||
let mut cmd = tokio::process::Command::new("systemctl");
|
||||
cmd.args(["--user", "is-active", "--quiet", &service]);
|
||||
cmd.kill_on_drop(true);
|
||||
if matches!(
|
||||
tokio::time::timeout(Duration::from_secs(2), cmd.status()).await,
|
||||
Ok(Ok(status)) if status.success()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn health_probe_url_for_app(app_id: &str) -> Option<String> {
|
||||
let port = match app_id {
|
||||
"bitcoin-ui" => 8334,
|
||||
"botfights" => 9100,
|
||||
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
|
||||
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
|
||||
"fedimint" | "fedimintd" => 8175,
|
||||
"filebrowser" => 8083,
|
||||
"gitea" => 3001,
|
||||
"grafana" => 3000,
|
||||
"homeassistant" | "home-assistant" => 8123,
|
||||
"immich" | "immich_server" => 2283,
|
||||
"indeedhub" => 7778,
|
||||
"jellyfin" => 8096,
|
||||
"lnd" | "lnd-ui" => 18083,
|
||||
"mempool" | "mempool-web" => 4080,
|
||||
"nginx-proxy-manager" => 8081,
|
||||
"ollama" => 11434,
|
||||
"photoprism" => 2342,
|
||||
"portainer" => 9000,
|
||||
"searxng" => 8888,
|
||||
"tailscale" => 8240,
|
||||
"uptime-kuma" => 3002,
|
||||
"vaultwarden" => 8082,
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("http://localhost:{port}"))
|
||||
}
|
||||
|
||||
fn requires_launch_port_for_health(app_id: &str) -> bool {
|
||||
matches!(app_id, "fedimint" | "fedimintd" | "fedimint-gateway")
|
||||
}
|
||||
|
||||
async fn launch_port_reachable(app_id: &str) -> bool {
|
||||
let Some(port) = health_probe_url_for_app(app_id).and_then(|url| port_from_url(&url)) else {
|
||||
return false;
|
||||
};
|
||||
launch_port_reachable_by_port(port).await
|
||||
}
|
||||
|
||||
async fn launch_port_reachable_by_port(port: u16) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn http_launch_url_reachable(url: &str) -> bool {
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match client.get(url).send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
status.is_success() || status.is_redirection()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn port_from_url(url: &str) -> Option<u16> {
|
||||
let after_colon = url.rsplit_once(':')?.1;
|
||||
let port = after_colon
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>();
|
||||
port.parse::<u16>().ok()
|
||||
}
|
||||
|
||||
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
if let Some(v) = ps_container_state_value(name).await {
|
||||
return Some(v);
|
||||
|
||||
@@ -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,8 @@ 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,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
@@ -97,6 +100,20 @@ impl RpcHandler {
|
||||
|
||||
// Bitcoin & Lightning deep data
|
||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||
"bitcoin.relay-status" => self.handle_bitcoin_relay_status().await,
|
||||
"bitcoin.relay-update-settings" => {
|
||||
self.handle_bitcoin_relay_update_settings(params).await
|
||||
}
|
||||
"bitcoin.relay-request-peer" => self.handle_bitcoin_relay_request_peer(params).await,
|
||||
"bitcoin.relay-approve-request" => {
|
||||
self.handle_bitcoin_relay_approve_request(params).await
|
||||
}
|
||||
"bitcoin.relay-reject-request" => {
|
||||
self.handle_bitcoin_relay_reject_request(params).await
|
||||
}
|
||||
"bitcoin.relay-create-tor-service" => {
|
||||
self.handle_bitcoin_relay_create_tor_service().await
|
||||
}
|
||||
"bitcoin.init-wallet-from-seed" => {
|
||||
self.handle_bitcoin_init_wallet_from_seed(params).await
|
||||
}
|
||||
@@ -221,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,
|
||||
@@ -234,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,
|
||||
@@ -253,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)
|
||||
@@ -364,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)
|
||||
@@ -455,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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ impl RpcHandler {
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
||||
.unwrap_or("");
|
||||
|
||||
// Validate SSID (prevent command injection)
|
||||
if ssid.len() > 64 || ssid.contains('\0') {
|
||||
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
||||
let networks: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(3, ':').collect();
|
||||
let parts = split_nmcli_escaped(line, 3);
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = line.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
current.push(next);
|
||||
}
|
||||
} else if ch == ':' && fields.len() + 1 < limit {
|
||||
fields.push(current);
|
||||
current = String::new();
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(current);
|
||||
fields
|
||||
}
|
||||
|
||||
/// Connect to a WiFi network using nmcli.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
@@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let mut args = vec![
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
"ipv6.method",
|
||||
"auto",
|
||||
];
|
||||
if !password.is_empty() {
|
||||
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args([
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-psk",
|
||||
"wifi-sec.psk",
|
||||
password,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
"ipv6.method",
|
||||
"auto",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi profile create")?;
|
||||
|
||||
@@ -38,6 +38,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -180,6 +181,7 @@ impl RpcHandler {
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
|
||||
@@ -63,6 +63,7 @@ impl RpcHandler {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
||||
@@ -9,24 +9,77 @@ use super::LND_REST_BASE_URL;
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
// LND's REST gateway parses `type` as the AddressType enum by its
|
||||
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
|
||||
// valid enum value and returns 400 "parsing field type"; the native
|
||||
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
|
||||
.query(&[("type", "WITNESS_PUBKEY_HASH")])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// The .116 case: LND container is up but its REST endpoint isn't
|
||||
// reachable on the expected port (e.g. published-port drift), so
|
||||
// the connection is refused. This is NOT a locked wallet — emit a
|
||||
// distinct code so the UI stops mislabelling it.
|
||||
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
|
||||
return Err(receive_error(
|
||||
RECEIVE_REST_UNREACHABLE,
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let status = resp.status();
|
||||
let raw_body = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to parse newaddress response")?;
|
||||
.context("Bitcoin address response could not be read")?;
|
||||
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
|
||||
serde_json::json!({
|
||||
"raw": raw_body,
|
||||
})
|
||||
});
|
||||
|
||||
if !status.is_success() {
|
||||
let message = lnd_error_message(&body);
|
||||
let code = classify_lnd_address_error(&message);
|
||||
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
if let Some(error) = body
|
||||
.get("error")
|
||||
.or_else(|| body.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let code = classify_lnd_address_error(error);
|
||||
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
|
||||
return Err(receive_error(code, default_receive_detail(code)));
|
||||
}
|
||||
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.filter(|addr| !addr.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
receive_error(
|
||||
RECEIVE_WALLET_UNINITIALIZED,
|
||||
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok(serde_json::json!({ "address": address }))
|
||||
@@ -98,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>,
|
||||
@@ -130,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
|
||||
@@ -499,12 +806,20 @@ 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).
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
@@ -543,3 +858,143 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn lnd_error_message(body: &serde_json::Value) -> String {
|
||||
body.get("message")
|
||||
.or_else(|| body.get("error"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("unknown LND error")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Stable, machine-readable reason codes for receive-address failures. They are
|
||||
// embedded in the error message as a `[CODE]` token so the frontend
|
||||
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
|
||||
// instead of guessing wallet state by substring-matching — which is what made
|
||||
// .228 report "wallet is locked" when LND's REST was merely unreachable.
|
||||
//
|
||||
// Every receive error string starts with "Bitcoin address" so it survives the
|
||||
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
|
||||
// flattened to the generic "Operation failed" message (the .116 symptom).
|
||||
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
|
||||
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
|
||||
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
|
||||
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
|
||||
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
|
||||
|
||||
/// Build a receive-address error carrying a reason code the UI can map.
|
||||
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
|
||||
}
|
||||
|
||||
/// A sensible default human message per code (used for non-UI callers and logs;
|
||||
/// the frontend renders its own copy from the code).
|
||||
fn default_receive_detail(code: &str) -> &'static str {
|
||||
match code {
|
||||
RECEIVE_REST_UNREACHABLE => {
|
||||
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
|
||||
}
|
||||
RECEIVE_WALLET_LOCKED => {
|
||||
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
|
||||
}
|
||||
RECEIVE_WALLET_UNINITIALIZED => {
|
||||
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
|
||||
}
|
||||
RECEIVE_SYNCING => {
|
||||
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
|
||||
}
|
||||
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a non-2xx LND error body/message into a reason code. The wording of
|
||||
/// LND's REST errors is stable enough to bucket: a locked wallet, an
|
||||
/// uninitialized wallet, a syncing chain, or some other failure.
|
||||
fn classify_lnd_address_error(message: &str) -> &'static str {
|
||||
let m = message.to_lowercase();
|
||||
if m.contains("locked") || m.contains("unlock") {
|
||||
RECEIVE_WALLET_LOCKED
|
||||
} else if m.contains("synchroniz")
|
||||
|| m.contains("syncing")
|
||||
|| m.contains("not yet ready")
|
||||
|| m.contains("in the process of starting")
|
||||
{
|
||||
RECEIVE_SYNCING
|
||||
} else if m.contains("wallet not found")
|
||||
|| m.contains("not exist")
|
||||
|| m.contains("uninitialized")
|
||||
|| m.contains("not initialized")
|
||||
|| m.contains("create a wallet")
|
||||
|| m.contains("no wallet")
|
||||
{
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
} else {
|
||||
RECEIVE_LND_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_prefers_message_field() {
|
||||
let body = serde_json::json!({
|
||||
"error": "grpc proxy error",
|
||||
"message": "wallet locked",
|
||||
});
|
||||
|
||||
assert_eq!(lnd_error_message(&body), "wallet locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_error_message_falls_back_to_unknown() {
|
||||
assert_eq!(
|
||||
lnd_error_message(&serde_json::json!({})),
|
||||
"unknown LND error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_locked_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet locked, please unlock"),
|
||||
RECEIVE_WALLET_LOCKED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_uninitialized_wallet() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("wallet not found, create a wallet first"),
|
||||
RECEIVE_WALLET_UNINITIALIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_syncing() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("server is still in the process of starting"),
|
||||
RECEIVE_SYNCING
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_is_generic_error() {
|
||||
assert_eq!(
|
||||
classify_lnd_address_error("some other failure"),
|
||||
RECEIVE_LND_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
|
||||
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
|
||||
// and carry the [CODE] token the frontend parses.
|
||||
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
|
||||
let s = format!("{err}");
|
||||
assert!(s.starts_with("Bitcoin address"), "got: {s}");
|
||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Failed to start",
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@@ -2,12 +2,14 @@ mod analytics;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
pub(crate) mod bitcoin_relay;
|
||||
mod container;
|
||||
mod content;
|
||||
mod credentials;
|
||||
mod dispatcher;
|
||||
mod dwn;
|
||||
mod federation;
|
||||
mod fedimint;
|
||||
mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
@@ -302,6 +304,7 @@ impl RpcHandler {
|
||||
| "system.stats"
|
||||
| "tor.status"
|
||||
| "tor.onion-addresses"
|
||||
| "bitcoin.relay-status"
|
||||
| "federation.list-nodes"
|
||||
| "system.get-settings"
|
||||
| "system.get-node-key"
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::port_allocator::PortAllocator;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
| "fips"
|
||||
| "fips-ui"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +219,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
|
||||
// VPN/mesh daemons need TUN + NET_ADMIN.
|
||||
// Note: --device=/dev/net/tun is added separately in install.rs
|
||||
"nostr-vpn" | "fips" => vec![
|
||||
"--cap-add=NET_ADMIN".to_string(),
|
||||
@@ -262,7 +264,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
|
||||
}
|
||||
"mempool-api" => (
|
||||
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
|
||||
@@ -312,11 +314,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"dwn" => (
|
||||
"curl -sf http://localhost:3000/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => return vec![],
|
||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||
@@ -352,18 +349,42 @@ 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: large cache materially speeds initial history indexing.
|
||||
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
|
||||
// socket buffers, and reorg/indexing spikes.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "4g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
// conservative and give the process headroom to avoid restart loops.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "6g",
|
||||
"cryptpad" => "512m",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
@@ -384,11 +405,11 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "256m",
|
||||
"filebrowser" => "256m",
|
||||
"searxng" => "512m",
|
||||
"dwn" => "256m",
|
||||
"portainer" => "256m",
|
||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||
"routstr" => "512m",
|
||||
"nostr-vpn" => "256m",
|
||||
"netbird" => "1g",
|
||||
"fips" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
@@ -494,6 +515,11 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"netbird" => vec![
|
||||
"netbird".into(),
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -582,6 +608,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
format!("{}/penpot-assets", base),
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
"netbird" => vec![format!("{}/netbird", base)],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
@@ -671,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).
|
||||
@@ -686,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(),
|
||||
]),
|
||||
@@ -749,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()],
|
||||
@@ -782,11 +816,9 @@ pub(super) async fn get_app_config(
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
// Sync-speed: bigger LRU/write cache during initial
|
||||
// history index. Default is 1200MB; the container gets
|
||||
// 4g (config.rs::get_memory_limit) so 3072 fits with
|
||||
// headroom.
|
||||
"CACHE_MB=3072".to_string(),
|
||||
// Keep cache below the container limit; high values
|
||||
// have caused OOM/restart loops during catch-up.
|
||||
"CACHE_MB=1024".to_string(),
|
||||
// Block-fetcher concurrency — defaults are conservative
|
||||
// for shared hosts; 4 is plenty for one bitcoind backend.
|
||||
"MAX_SEND=10000000".to_string(),
|
||||
@@ -959,6 +991,7 @@ pub(super) async fn get_app_config(
|
||||
vec![
|
||||
"/var/lib/archipelago/portainer:/data".to_string(),
|
||||
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
|
||||
"/var/lib/archipelago/portainer/compose:/data/compose".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
@@ -988,7 +1021,7 @@ pub(super) async fn get_app_config(
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
@@ -1121,18 +1154,6 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
"dwn" => (
|
||||
vec!["3100:3000".to_string()],
|
||||
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
|
||||
vec![
|
||||
"DS_PORT=3000".to_string(),
|
||||
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
|
||||
"DS_DATA_STORE_URI=level://data/data".to_string(),
|
||||
"DS_EVENT_LOG_URI=level://data/events".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"botfights" => {
|
||||
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
|
||||
(
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -288,6 +360,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -389,6 +462,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netbird_start_order_starts_server_before_dashboard() {
|
||||
assert_eq!(
|
||||
startup_order("netbird"),
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
for package_id in [
|
||||
|
||||
@@ -13,11 +13,12 @@ use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use crate::update::host_sudo;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
|
||||
const IMAGE_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Append a timestamped line to the persistent install log.
|
||||
pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
@@ -34,6 +35,36 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_podman_image_exists(image: &str) -> Result<bool> {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["image", "inspect", image]);
|
||||
cmd.kill_on_drop(true);
|
||||
let output = timeout(IMAGE_INSPECT_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"podman image inspect {} timed out after {}s",
|
||||
image,
|
||||
IMAGE_INSPECT_TIMEOUT.as_secs()
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("Failed to execute podman image inspect {}", image))?;
|
||||
match output.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
Some(code) => Err(anyhow::anyhow!(
|
||||
"podman image inspect {} exited with {}: {}",
|
||||
image,
|
||||
code,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"podman image inspect {} terminated by signal",
|
||||
image
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn patch_indeedhub_nostr_provider() {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
@@ -241,7 +272,9 @@ impl RpcHandler {
|
||||
if package_id == "indeedhub" {
|
||||
return self.install_indeedhub_stack().await;
|
||||
}
|
||||
|
||||
if package_id == "netbird" {
|
||||
return self.install_netbird_stack().await;
|
||||
}
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
@@ -441,6 +474,7 @@ impl RpcHandler {
|
||||
Ok(container_name) => {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
ensure_host_port_listener(package_id, &container_name, &[]).await?;
|
||||
crate::api::rpc::package::runtime::reconcile_companions_for(package_id)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
@@ -646,10 +680,6 @@ impl RpcHandler {
|
||||
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
|
||||
}
|
||||
|
||||
if package_id == "portainer" {
|
||||
ensure_user_podman_socket().await?;
|
||||
}
|
||||
|
||||
// Pre-install: SearXNG settings.yml (required or container exits immediately)
|
||||
if package_id == "searxng" {
|
||||
let searx_dir = "/var/lib/archipelago/searxng";
|
||||
@@ -742,16 +772,10 @@ impl RpcHandler {
|
||||
.await;
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
// Build command with optional custom command/args
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(&run_args);
|
||||
if let Some(custom_cmd) = custom_command {
|
||||
cmd.arg(custom_cmd);
|
||||
} else if let Some(args) = custom_args {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let mut run_output = cmd.output().await.context("Failed to run container")?;
|
||||
let command_tail = install_command_tail(custom_command.as_deref(), custom_args.as_ref());
|
||||
let mut run_output = podman_run_for_install(package_id, &run_args, &command_tail)
|
||||
.await
|
||||
.context("Failed to run container")?;
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
|
||||
@@ -760,7 +784,9 @@ impl RpcHandler {
|
||||
.args(["rm", "-f", container_name])
|
||||
.output()
|
||||
.await;
|
||||
run_output = cmd.output().await.context("Failed to rerun container")?;
|
||||
run_output = podman_run_for_install(package_id, &run_args, &command_tail)
|
||||
.await
|
||||
.context("Failed to rerun container")?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,12 +942,7 @@ impl RpcHandler {
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", &local_tag])
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
check.is_some_and(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
|
||||
local_podman_image_exists(&local_tag).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -936,14 +957,9 @@ impl RpcHandler {
|
||||
);
|
||||
} else {
|
||||
// Local image — verify it exists
|
||||
let images_output = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
if !local_podman_image_exists(docker_image)
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
.context("Failed to check local image")?
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Build the image first \
|
||||
@@ -1133,12 +1149,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// Verify image exists locally after pull.
|
||||
let verify = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
if !local_podman_image_exists(docker_image)
|
||||
.await
|
||||
.context("Failed to verify pulled image")?;
|
||||
if String::from_utf8_lossy(&verify.stdout).trim().is_empty() {
|
||||
.context("Failed to verify pulled image")?
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Image {} not found locally after pull",
|
||||
docker_image
|
||||
@@ -1272,11 +1286,13 @@ impl RpcHandler {
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
);
|
||||
@@ -1853,6 +1869,47 @@ autopilot.active=false\n",
|
||||
|
||||
Ok(serde_json::json!({ "token": token }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_package_credentials(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let app_id = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("app_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
super::validation::validate_app_id(app_id)?;
|
||||
|
||||
if app_id == "filebrowser" {
|
||||
let password =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
|
||||
.await
|
||||
.map(|p| p.trim().to_string())
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
return Ok(serde_json::json!({
|
||||
"title": "File Browser credentials",
|
||||
"description": "Use these credentials when File Browser asks you to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
if app_id == "photoprism" {
|
||||
return Ok(serde_json::json!({
|
||||
"title": "PhotoPrism credentials",
|
||||
"description": "Use these credentials when PhotoPrism asks you to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": "archipelago", "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "credentials": [] }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
@@ -1872,10 +1929,128 @@ async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
"portainer" => cleanup_stale_pasta_port("9000").await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_command_tail(
|
||||
custom_cmd: Option<&str>,
|
||||
custom_args: Option<&Vec<String>>,
|
||||
) -> Vec<String> {
|
||||
if let Some(cmd) = custom_cmd {
|
||||
vec![cmd.to_string()]
|
||||
} else if let Some(args) = custom_args {
|
||||
args.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn podman_run_for_install(
|
||||
package_id: &str,
|
||||
run_args: &[&str],
|
||||
command_tail: &[String],
|
||||
) -> Result<std::process::Output> {
|
||||
if should_scope_podman_run(package_id) {
|
||||
match podman_create_then_scoped_start(package_id, run_args, command_tail).await {
|
||||
Ok(output) => return Ok(output),
|
||||
Err(err) => {
|
||||
tracing::warn!(package_id, error = %err, "scoped podman create/start failed; falling back to direct podman run");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(run_args);
|
||||
cmd.args(command_tail);
|
||||
cmd.output().await.context("Failed to run podman")
|
||||
}
|
||||
|
||||
async fn podman_create_then_scoped_start(
|
||||
package_id: &str,
|
||||
run_args: &[&str],
|
||||
command_tail: &[String],
|
||||
) -> Result<std::process::Output> {
|
||||
let container_name = run_args
|
||||
.windows(2)
|
||||
.find_map(|pair| (pair[0] == "--name").then_some(pair[1]))
|
||||
.unwrap_or(package_id);
|
||||
let mut create_args = Vec::with_capacity(run_args.len() + command_tail.len());
|
||||
for (idx, arg) in run_args.iter().enumerate() {
|
||||
if idx == 0 && *arg == "run" {
|
||||
create_args.push("create".to_string());
|
||||
} else if *arg != "-d" {
|
||||
create_args.push((*arg).to_string());
|
||||
}
|
||||
}
|
||||
create_args.extend(command_tail.iter().cloned());
|
||||
|
||||
let mut create = tokio::process::Command::new("podman");
|
||||
create.args(&create_args);
|
||||
let create_output = create
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman create")?;
|
||||
if !create_output.status.success() {
|
||||
return Ok(create_output);
|
||||
}
|
||||
|
||||
let mut scoped_start = tokio::process::Command::new("systemd-run");
|
||||
scoped_start.args([
|
||||
"--user",
|
||||
"--scope",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"podman",
|
||||
"start",
|
||||
container_name,
|
||||
]);
|
||||
match scoped_start.output().await {
|
||||
Ok(output) if output.status.success() => Ok(create_output),
|
||||
Ok(output) => {
|
||||
tracing::warn!(
|
||||
package_id,
|
||||
container = container_name,
|
||||
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
|
||||
"scoped podman start after create failed; trying direct podman start"
|
||||
);
|
||||
let mut direct_start = tokio::process::Command::new("podman");
|
||||
direct_start.args(["start", container_name]);
|
||||
let direct_output = direct_start
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run fallback podman start")?;
|
||||
if direct_output.status.success() {
|
||||
Ok(create_output)
|
||||
} else {
|
||||
Ok(direct_output)
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err).context("Failed to run scoped podman start"),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_scope_podman_run(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"botfights"
|
||||
| "filebrowser"
|
||||
| "gitea"
|
||||
| "grafana"
|
||||
| "homeassistant"
|
||||
| "home-assistant"
|
||||
| "jellyfin"
|
||||
| "nginx-proxy-manager"
|
||||
| "nostr-rs-relay"
|
||||
| "photoprism"
|
||||
| "portainer"
|
||||
| "searxng"
|
||||
| "uptime-kuma"
|
||||
| "vaultwarden"
|
||||
)
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
if stderr.contains("name is already in use") || stderr.contains("name \"") {
|
||||
return true;
|
||||
@@ -1926,6 +2101,12 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
true
|
||||
}
|
||||
"portainer"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("9000").await;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1984,7 +2165,7 @@ async fn ensure_host_port_listener(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_host_port(port, 10).await {
|
||||
if wait_for_host_port(package_id, port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -2010,7 +2191,7 @@ async fn ensure_host_port_listener(
|
||||
));
|
||||
}
|
||||
|
||||
if wait_for_host_port(port, 60).await {
|
||||
if wait_for_host_port(package_id, port, 60).await {
|
||||
install_log(&format!(
|
||||
"INSTALL REPAIR OK: {} — host port {} is listening after restart",
|
||||
package_id, port
|
||||
@@ -2042,31 +2223,6 @@ fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = tokio::process::Command::new("systemctl")
|
||||
.args(["--user", "restart", "podman.socket"])
|
||||
.status()
|
||||
.await
|
||||
.context("spawn systemctl --user restart podman.socket")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemctl --user restart podman.socket exited {status}");
|
||||
}
|
||||
|
||||
for _ in 0..20 {
|
||||
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
anyhow::bail!("podman socket {socket_path} did not appear after restart")
|
||||
}
|
||||
|
||||
fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
match package_id {
|
||||
"grafana" => Some(3000),
|
||||
@@ -2076,17 +2232,21 @@ fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
"gitea" => Some(3001),
|
||||
"nextcloud" => Some(8085),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
"portainer" => Some(9000),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
async fn wait_for_host_port(package_id: &str, port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let ready = match package_id {
|
||||
"uptime-kuma" => http_host_port_ready(port, "/").await,
|
||||
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok(),
|
||||
};
|
||||
if ready {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2098,6 +2258,36 @@ async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_host_port_ready(port: u16, path: &str) -> bool {
|
||||
let Ok(Ok(mut stream)) = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
|
||||
if stream.write_all(request.as_bytes()).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 128];
|
||||
let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
head.starts_with("HTTP/1.1 2")
|
||||
|| head.starts_with("HTTP/1.1 3")
|
||||
|| head.starts_with("HTTP/1.0 2")
|
||||
|| head.starts_with("HTTP/1.0 3")
|
||||
}
|
||||
|
||||
/// Resolve the host gateway IP for --add-host flag.
|
||||
/// Resolve the default gateway IP from the routing table for --add-host flag.
|
||||
/// Explicit IP avoids issues with "host-gateway" in rootless Podman.
|
||||
@@ -2193,6 +2383,18 @@ set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
tmp=$(mktemp)
|
||||
awk -F= '
|
||||
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
|
||||
if (seen[$1]++) next
|
||||
}
|
||||
{ print }
|
||||
' "$conf" > "$tmp"
|
||||
if ! cmp -s "$conf" "$tmp"; then
|
||||
cat "$tmp" > "$conf"
|
||||
changed=1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
@@ -2204,6 +2406,8 @@ ensure_line() {
|
||||
ensure_line server=1
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
ensure_line rpcthreads=16
|
||||
ensure_line rpcworkqueue=256
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
exit 2
|
||||
"#;
|
||||
@@ -2230,6 +2434,7 @@ fn should_try_orchestrator_install(package_id: &str, orchestrator_available: boo
|
||||
fn orchestrator_install_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
"home-assistant" => "homeassistant",
|
||||
_ => package_id,
|
||||
}
|
||||
}
|
||||
@@ -2257,6 +2462,16 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "btcpay-server"
|
||||
| "homeassistant"
|
||||
| "home-assistant"
|
||||
| "nextcloud"
|
||||
| "vaultwarden"
|
||||
| "jellyfin"
|
||||
| "photoprism"
|
||||
| "uptime-kuma"
|
||||
| "gitea"
|
||||
| "portainer"
|
||||
| "meshtastic"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2294,6 +2509,16 @@ mod tests {
|
||||
"archy-btcpay-db",
|
||||
"archy-nbxplorer",
|
||||
"btcpay-server",
|
||||
"homeassistant",
|
||||
"home-assistant",
|
||||
"nextcloud",
|
||||
"vaultwarden",
|
||||
"jellyfin",
|
||||
"photoprism",
|
||||
"uptime-kuma",
|
||||
"gitea",
|
||||
"portainer",
|
||||
"meshtastic",
|
||||
] {
|
||||
assert!(uses_orchestrator_install_flow(app));
|
||||
assert!(should_try_orchestrator_install(app, true));
|
||||
@@ -2322,6 +2547,10 @@ mod tests {
|
||||
assert_eq!(orchestrator_install_app_id("bitcoin-core"), "bitcoin-core");
|
||||
assert_eq!(orchestrator_install_app_id("electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
|
||||
assert_eq!(
|
||||
orchestrator_install_app_id("home-assistant"),
|
||||
"homeassistant"
|
||||
);
|
||||
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user