Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc94445ca0 | ||
|
|
04cf0f663a | ||
|
|
576c642da4 | ||
|
|
12866db84a | ||
|
|
a184254706 | ||
|
|
192e045426 | ||
|
|
9ac46a69f8 | ||
|
|
bf6ef9644c | ||
|
|
c32910809e | ||
|
|
d2174128c5 | ||
|
|
2ad0171e5f | ||
|
|
46cb0bfd37 | ||
|
|
b8593c9090 | ||
|
|
fc68c5b680 | ||
|
|
3ed75c328d | ||
|
|
687196ad3b | ||
|
|
e2bd6330a1 | ||
|
|
7c0a492c43 | ||
|
|
3089624969 | ||
|
|
5b658cec67 | ||
|
|
21b8d4b1ee | ||
|
|
6f05f5583f | ||
|
|
02ac4396d1 | ||
|
|
5ffdcc9936 | ||
|
|
9cf07e1eac | ||
|
|
e7854702c0 | ||
|
|
d4018a6e73 | ||
|
|
b57cba63d1 | ||
|
|
7bc9f69b1f | ||
|
|
913743923c | ||
|
|
241e8cfca4 | ||
|
|
017505c431 | ||
|
|
7a39d8fbd1 | ||
|
|
e3275353b9 | ||
|
|
9f1a289d1a | ||
|
|
dd07da53f9 | ||
|
|
7d09418a09 | ||
|
|
b927461f8e | ||
|
|
8cf45377a2 | ||
|
|
4efac99e97 | ||
|
|
981296e8b0 | ||
|
|
22f8129b52 | ||
|
|
57e31eb192 | ||
|
|
12c853da45 | ||
|
|
d259f3cbb9 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
versionCode = 48
|
||||
versionName = "0.5.28"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
<!-- Remote-signer pairing deep link (NIP-46, companion 0.5.28):
|
||||
nostrconnect://<client-pubkey>?relay=...&secret=... — the
|
||||
node's login QR, hand-off from any QR scanner app. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrconnect" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI binding to the companion's non-mesh native surface (same
|
||||
* libarchy_fips_core.so as FipsNative — backup + nostr signer crypto, built
|
||||
* from Android/rust/archy-fips-core).
|
||||
*
|
||||
* Same contract as FipsNative: JSON over strings, failures come back as
|
||||
* {"error": "…"} rather than exceptions, and [available] is false on ABIs
|
||||
* the .so isn't built for so every caller can degrade gracefully.
|
||||
*/
|
||||
object NativeCore {
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("archy_fips_core")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Backup (#128): the node's ADR-005 envelope ──────────────────────────
|
||||
|
||||
/** Encrypt a JSON payload into an ADR-005 envelope (ChaCha20-Poly1305). */
|
||||
external fun backupEncrypt(payload: String, passphrase: String): String
|
||||
|
||||
/** Decrypt an ADR-005 envelope back to its payload JSON. */
|
||||
external fun backupDecrypt(envelope: String, passphrase: String): String
|
||||
|
||||
// ── NIP-46 remote signer (#139) ─────────────────────────────────────────
|
||||
|
||||
/** Generate a fresh nostr key: {"secret","pubkey","npub","nsec"}. */
|
||||
external fun nostrGenerateSecret(): String
|
||||
|
||||
/** Import a key from hex or nsec…: {"secret","pubkey","npub","nsec"}. */
|
||||
external fun nostrSecretFromAny(secret: String): String
|
||||
|
||||
/** Parse nostrconnect://…: {"clientPubkey","relays":[…],"secret","perms","name","url","image"}. */
|
||||
external fun nostrParseConnectUri(uri: String): String
|
||||
|
||||
/**
|
||||
* Sign `{kind, content, tags, created_at}` with the signer key: returns
|
||||
* the full signed event JSON. Approval happens BEFORE this call — the
|
||||
* native side never signs unasked.
|
||||
*/
|
||||
external fun nostrSignEvent(secretHex: String, eventJson: String): String
|
||||
|
||||
/** NIP-44 v2 encrypt/decrypt; result JSON: {"result": payload} or {"error": …}. */
|
||||
external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||
external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||
|
||||
/** NIP-04 fallback (deprecated but still spoken by real clients). */
|
||||
external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String
|
||||
external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String
|
||||
|
||||
/** True when a native reply is an error envelope. */
|
||||
fun isErr(json: String): Boolean = try {
|
||||
JSONObject(json).has("error")
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
|
||||
/** Error text from a native reply, or a generic message if malformed. */
|
||||
fun errMsg(json: String): String = try {
|
||||
JSONObject(json).optString("error", "native call failed")
|
||||
} catch (_: Exception) {
|
||||
"native call failed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.archipelago.app.NativeCore
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.nostr.NostrSignerPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Companion backup & restore (#128) — the phone side of "losing your phone,
|
||||
* or wiping it to cross a border".
|
||||
*
|
||||
* The payload (servers + FIPS identity/peers + signer key + flags) is
|
||||
* serialized to JSON and sealed into the node's ADR-005 envelope (Argon2id +
|
||||
* ChaCha20-Poly1305) by the native core — the SAME envelope the node uses,
|
||||
* not a second format. The passphrase never leaves the encrypt call.
|
||||
*
|
||||
* Transport is deliberately boring: a plain .json file the user saves via
|
||||
* the system file picker (SAF) — on GrapheneOS there is no cloud backup and
|
||||
* there should be none here either; the file goes wherever the user puts it
|
||||
* (USB drive, computer, a folder synced their way).
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
|
||||
private val servers = ServerPreferences(context)
|
||||
private val fips = FipsPreferences(context)
|
||||
private val signer = NostrSignerPreferences(context)
|
||||
|
||||
/** Everything the backup captures, for the restore preview UI. */
|
||||
data class PayloadSummary(
|
||||
val serverCount: Int,
|
||||
val hasFipsIdentity: Boolean,
|
||||
val hasSignerKey: Boolean,
|
||||
val appVersion: String,
|
||||
)
|
||||
|
||||
/** What a restore actually did, for the result UI. */
|
||||
data class RestoreResult(
|
||||
val serversRestored: Int,
|
||||
val activeSet: Boolean,
|
||||
val fipsIdentityRestored: Boolean,
|
||||
val signerKeyRestored: Boolean,
|
||||
)
|
||||
|
||||
private fun appVersion(): String = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the encrypted backup envelope. Runs on IO: DataStore reads
|
||||
* plus the Argon2id KDF (tens of ms) + AEAD.
|
||||
*/
|
||||
suspend fun createBackup(passphrase: String): String = withContext(Dispatchers.IO) {
|
||||
require(passphrase.isNotEmpty()) { "passphrase required" }
|
||||
|
||||
val active = servers.activeServer.first()
|
||||
val saved = servers.savedServers.first()
|
||||
val fipsId = fips.identity()
|
||||
val peers = fips.peersJson()
|
||||
val partyPeers = fips.partyPeers()
|
||||
val partyName = fips.partyName()
|
||||
val partyListen = fips.partyListen()
|
||||
val signerSecret = signer.secret()
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("app", "archipelago-companion")
|
||||
put("payloadVersion", 1)
|
||||
put("appVersion", appVersion())
|
||||
put("createdAt", System.currentTimeMillis() / 1000)
|
||||
put("servers", JSONArray(saved.map { it.serialize() }))
|
||||
put("active", active?.serialize() ?: JSONObject.NULL)
|
||||
if (fipsId != null) {
|
||||
put("fips", JSONObject().apply {
|
||||
put("secret", fipsId.secret)
|
||||
put("npub", fipsId.npub)
|
||||
put("address", fipsId.address)
|
||||
put("peers", JSONArray(peers))
|
||||
put("partyPeers", JSONArray().apply { partyPeers.forEach { put(JSONObject().apply {
|
||||
put("npub", it.npub); put("ula", it.ula); put("name", it.name)
|
||||
put("ip", it.ip); put("port", it.port)
|
||||
}) } })
|
||||
put("partyName", partyName)
|
||||
put("partyListen", partyListen)
|
||||
})
|
||||
}
|
||||
if (signerSecret != null) {
|
||||
put("signer", JSONObject().apply { put("secret", signerSecret) })
|
||||
}
|
||||
put("flags", JSONObject().apply {
|
||||
put("introSeen", servers.introSeen.first())
|
||||
})
|
||||
}
|
||||
|
||||
val envelope = NativeCore.backupEncrypt(payload.toString(), passphrase)
|
||||
if (NativeCore.isErr(envelope)) throw BackupException(NativeCore.errMsg(envelope))
|
||||
envelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at a decrypted backup (passphrase already checked) to preview what
|
||||
* a restore would do. Does NOT touch any stored state.
|
||||
*/
|
||||
suspend fun readBackup(envelope: String, passphrase: String): Pair<PayloadSummary, JSONObject> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val payload = NativeCore.backupDecrypt(envelope, passphrase)
|
||||
if (NativeCore.isErr(payload)) throw BackupException(NativeCore.errMsg(payload))
|
||||
val obj = JSONObject(payload)
|
||||
if (obj.optString("app") != "archipelago-companion") {
|
||||
throw BackupException("Not a companion backup (this may be a node backup — restore it on the node)")
|
||||
}
|
||||
val summary = PayloadSummary(
|
||||
serverCount = obj.optJSONArray("servers")?.length() ?: 0,
|
||||
hasFipsIdentity = obj.has("fips"),
|
||||
hasSignerKey = obj.has("signer"),
|
||||
appVersion = obj.optString("appVersion", ""),
|
||||
)
|
||||
summary to obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decrypted backup to this install. Merge semantics — a restore
|
||||
* never silently destroys what's already here:
|
||||
*
|
||||
* - Servers upsert (npub-first, [ServerPreferences.upsertServer]) — same
|
||||
* identity merges, never duplicates.
|
||||
* - The backup's active server is set active only when none is.
|
||||
* - FIPS identity/peers restore only when this phone has none (a phone
|
||||
* that already paired has a live identity the node peers with; swapping
|
||||
* it from a backup would strand the current pairing). Peers merge by
|
||||
* npub otherwise.
|
||||
* - Signer key restores only when none exists locally.
|
||||
*/
|
||||
suspend fun restoreBackup(payload: JSONObject): RestoreResult = withContext(Dispatchers.IO) {
|
||||
val serverArray = payload.optJSONArray("servers") ?: JSONArray()
|
||||
var restored = 0
|
||||
for (i in 0 until serverArray.length()) {
|
||||
val raw = serverArray.optString(i)
|
||||
val entry = ServerEntry.deserialize(raw) ?: continue
|
||||
servers.upsertServer(entry)
|
||||
restored++
|
||||
}
|
||||
|
||||
var activeSet = false
|
||||
val activeStr = if (payload.isNull("active")) null else payload.optString("active", "")
|
||||
val activeEntry = activeStr?.takeIf { it.isNotBlank() }?.let { ServerEntry.deserialize(it) }
|
||||
if (activeEntry != null && servers.activeServer.first() == null) {
|
||||
servers.setActiveServer(activeEntry)
|
||||
activeSet = true
|
||||
}
|
||||
|
||||
// FIPS identity: only adopt when this phone has none.
|
||||
var fipsRestored = false
|
||||
val fipsObj = payload.optJSONObject("fips")
|
||||
if (fipsObj != null && fips.identity() == null) {
|
||||
val secret = fipsObj.optString("secret")
|
||||
if (secret.isNotBlank()) {
|
||||
fips.saveIdentity(
|
||||
com.archipelago.app.fips.FipsNative.Identity(
|
||||
secret = secret,
|
||||
npub = fipsObj.optString("npub"),
|
||||
address = fipsObj.optString("address"),
|
||||
)
|
||||
)
|
||||
fipsRestored = true
|
||||
}
|
||||
// Peers: union by npub with whatever is already here (an empty
|
||||
// store takes the backup's list wholesale).
|
||||
val backupPeers = fipsObj.optJSONArray("peers")?.let { arr ->
|
||||
(0 until arr.length()).joinToString(",", "[", "]") { arr.optString(it) }
|
||||
} ?: "[]"
|
||||
fips.mergePeersJson(backupPeers)
|
||||
|
||||
val partyArr = fipsObj.optJSONArray("partyPeers")
|
||||
if (partyArr != null) {
|
||||
for (i in 0 until partyArr.length()) {
|
||||
val p = partyArr.optJSONObject(i) ?: continue
|
||||
val npub = p.optString("npub")
|
||||
val ula = p.optString("ula")
|
||||
if (npub.isNotBlank() && ula.isNotBlank()) {
|
||||
fips.upsertPartyPeer(
|
||||
com.archipelago.app.fips.PartyPeer(
|
||||
npub = npub, ula = ula,
|
||||
name = p.optString("name").ifBlank { "Phone" },
|
||||
ip = p.optString("ip"), port = p.optInt("port"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fipsObj.optString("partyName").isNotBlank()) {
|
||||
fips.setPartyName(fipsObj.optString("partyName"))
|
||||
}
|
||||
fips.setPartyListen(fipsObj.optBoolean("partyListen", false))
|
||||
}
|
||||
|
||||
// Signer key: only adopt when none exists locally.
|
||||
var signerRestored = false
|
||||
val signerObj = payload.optJSONObject("signer")
|
||||
if (signerObj != null && signer.secret() == null) {
|
||||
val secret = signerObj.optString("secret")
|
||||
if (secret.isNotBlank()) {
|
||||
signer.saveSecret(secret)
|
||||
signerRestored = true
|
||||
}
|
||||
}
|
||||
|
||||
// Flags: a user who completed the intro on the old phone shouldn't
|
||||
// see it again on the new one.
|
||||
val flags = payload.optJSONObject("flags")
|
||||
if (flags?.optBoolean("introSeen", false) == true) {
|
||||
servers.markIntroSeen()
|
||||
}
|
||||
|
||||
RestoreResult(
|
||||
serversRestored = restored,
|
||||
activeSet = activeSet,
|
||||
fipsIdentityRestored = fipsRestored,
|
||||
signerKeyRestored = signerRestored,
|
||||
)
|
||||
}
|
||||
|
||||
class BackupException(message: String) : Exception(message)
|
||||
}
|
||||
@@ -89,6 +89,38 @@ class FipsPreferences(private val context: Context) {
|
||||
|
||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||
|
||||
/**
|
||||
* Union the stored node peers with a backup's peer list, matched by
|
||||
* npub — the backup's copy wins for the same npub (its addresses are what
|
||||
* the restored identity pairs against). Used by companion restore (#128)
|
||||
* after [saveIdentity] adopted the backup's mesh identity.
|
||||
*/
|
||||
suspend fun mergePeersJson(incomingJson: String) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
val incoming = try {
|
||||
JSONArray(incomingJson)
|
||||
} catch (_: Exception) {
|
||||
JSONArray()
|
||||
}
|
||||
val incomingNpubs = mutableSetOf<String>()
|
||||
val merged = JSONArray()
|
||||
for (i in 0 until incoming.length()) {
|
||||
val peer = incoming.optJSONObject(i) ?: continue
|
||||
val npub = peer.optString("npub")
|
||||
if (npub.isNotBlank()) {
|
||||
incomingNpubs.add(npub)
|
||||
merged.put(peer)
|
||||
}
|
||||
}
|
||||
for (i in 0 until current.length()) {
|
||||
val peer = current.optJSONObject(i) ?: continue
|
||||
if (peer.optString("npub") !in incomingNpubs) merged.put(peer)
|
||||
}
|
||||
prefs[peersKey] = merged.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||
|
||||
suspend fun partyListen(): Boolean =
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
package com.archipelago.app.nostr
|
||||
|
||||
import android.content.Context
|
||||
import com.archipelago.app.NativeCore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* NIP-46 remote-signer session (#139) — the phone side, wire-faithful to
|
||||
* rust-nostr's reference bunker (`signer/nostr-connect/src/signer.rs`),
|
||||
* which the node's login flow will interoperate with:
|
||||
*
|
||||
* 1. Client (the node's login page) shows a `nostrconnect://` QR.
|
||||
* 2. We scan it, connect to its relay, subscribe to kind-24133 events
|
||||
* p-tagged to our signer key, and send a `connect` request carrying the
|
||||
* secret (the client validates it and answers "ack").
|
||||
* 3. Requests arrive as NIP-44-encrypted kind-24133 events; we respond over
|
||||
* the same channel. `sign_event` is the one method that never runs
|
||||
* without a human tapping Approve on this phone.
|
||||
*
|
||||
* The session lives while the app is around (the login handshake takes
|
||||
* seconds); there is no background service in v1 and no remembered-session
|
||||
* auto-reconnect (research doc flow C — deferred deliberately).
|
||||
*/
|
||||
object BunkerManager {
|
||||
|
||||
sealed class SignerState {
|
||||
/** Native core unavailable (e.g. x86 emulator) — signing impossible. */
|
||||
object Unavailable : SignerState()
|
||||
/** Key exists, no session. */
|
||||
object Idle : SignerState()
|
||||
/** No signer key generated/imported yet. */
|
||||
object NoKey : SignerState()
|
||||
data class Connecting(val relay: String) : SignerState()
|
||||
/** Connect request sent; waiting for the client to ack. */
|
||||
data class AwaitingClient(val relay: String, val clientName: String) : SignerState()
|
||||
/** Handshake complete — this is the state where requests are answered. */
|
||||
data class Ready(val relay: String, val clientName: String) : SignerState()
|
||||
data class Failed(val reason: String) : SignerState()
|
||||
}
|
||||
|
||||
/** One signature request awaiting a human decision. */
|
||||
data class PendingRequest(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val clientPubkey: String,
|
||||
val clientName: String,
|
||||
val kind: Long?,
|
||||
val content: String?,
|
||||
/** Formatted tag lines for the approval card. */
|
||||
val tags: List<String>,
|
||||
val createdAt: Long?,
|
||||
/** The full unsigned event JSON handed to the native signer on approve. */
|
||||
val unsignedEventJson: String,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow<SignerState>(SignerState.Idle)
|
||||
val state: StateFlow<SignerState> = _state.asStateFlow()
|
||||
|
||||
private val _pending = MutableStateFlow<PendingRequest?>(null)
|
||||
val pending: StateFlow<PendingRequest?> = _pending.asStateFlow()
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.pingInterval(25, TimeUnit.SECONDS) // relay keepalive
|
||||
.build()
|
||||
|
||||
private data class Session(
|
||||
val socket: WebSocket,
|
||||
val relay: String,
|
||||
/** The client's pubkey (hex) from the nostrconnect URI. */
|
||||
val clientPubkey: String,
|
||||
val clientName: String,
|
||||
/** The pairing secret — echoed back during handshake, then kept for
|
||||
* validating an incoming `connect` from the same client. */
|
||||
val secret: String,
|
||||
/** Our connect request id, to match the client's ack response. */
|
||||
val connectRequestId: String,
|
||||
/** Our signer secret (hex). */
|
||||
val signerSecretHex: String,
|
||||
/** Our signer pubkey (hex). */
|
||||
val signerPubkeyHex: String,
|
||||
/** Event ids already handled (relays may redeliver). */
|
||||
val seen: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet(),
|
||||
)
|
||||
|
||||
private val session = AtomicReference<Session?>(null)
|
||||
|
||||
/** Refresh Idle/NoKey state (suspend; call from a coroutine — DataStore reads hit disk). */
|
||||
suspend fun refreshState(context: Context) {
|
||||
if (!NativeCore.available) {
|
||||
_state.value = SignerState.Unavailable
|
||||
return
|
||||
}
|
||||
if (session.get() != null) return
|
||||
val prefs = NostrSignerPreferences(context.applicationContext)
|
||||
_state.value =
|
||||
if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair from a scanned or deep-linked `nostrconnect://…` URI. Returns a
|
||||
* user-presentable error on failure, or null on success (state moves to
|
||||
* Connecting → AwaitingClient).
|
||||
*/
|
||||
suspend fun pair(context: Context, uri: String): String? {
|
||||
if (!NativeCore.available) return "Signing is unavailable on this device"
|
||||
val appContext = context.applicationContext
|
||||
return withContext(Dispatchers.IO) {
|
||||
val parsed = JSONObject(NativeCore.nostrParseConnectUri(uri.trim()))
|
||||
if (parsed.has("error")) return@withContext parsed.getString("error")
|
||||
|
||||
val prefs = NostrSignerPreferences(appContext)
|
||||
val secret = prefs.secret()
|
||||
?: return@withContext "No signer key yet — generate or import one first"
|
||||
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
|
||||
if (info.has("error")) return@withContext info.getString("error")
|
||||
|
||||
val clientPubkey = parsed.getString("clientPubkey")
|
||||
val relays = mutableListOf<String>()
|
||||
parsed.optJSONArray("relays")?.let { arr -> for (i in 0 until arr.length()) relays.add(arr.optString(i)) }
|
||||
val clientName = parsed.optString("name").ifBlank { "client" }
|
||||
val pairSecret = parsed.getString("secret")
|
||||
|
||||
if (relays.isEmpty()) return@withContext "The pairing code carries no relay to reach the client on"
|
||||
|
||||
teardown()
|
||||
|
||||
var lastError = "no relay could be reached"
|
||||
for (relay in relays) {
|
||||
_state.value = SignerState.Connecting(relay)
|
||||
val opened = openSession(
|
||||
relay, clientPubkey, clientName, pairSecret, secret, info,
|
||||
)
|
||||
if (opened != null) {
|
||||
session.set(opened)
|
||||
prefs.savePairing(
|
||||
NostrSignerPreferences.Pairing(clientPubkey, relay, clientName)
|
||||
)
|
||||
_state.value = SignerState.AwaitingClient(relay, clientName)
|
||||
return@withContext null
|
||||
}
|
||||
lastError = "relay $relay did not answer"
|
||||
}
|
||||
_state.value = SignerState.Failed(lastError)
|
||||
lastError
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-establish the last saved pairing without a fresh QR. */
|
||||
suspend fun resume(context: Context): String? {
|
||||
if (!NativeCore.available) return "Signing is unavailable on this device"
|
||||
val appContext = context.applicationContext
|
||||
return withContext(Dispatchers.IO) {
|
||||
val prefs = NostrSignerPreferences(appContext)
|
||||
val pairing = prefs.lastPairing()
|
||||
?: return@withContext "Nothing to resume — no saved pairing"
|
||||
val secret = prefs.secret()
|
||||
?: return@withContext "No signer key"
|
||||
val info = JSONObject(NativeCore.nostrSecretFromAny(secret))
|
||||
if (info.has("error")) return@withContext info.getString("error")
|
||||
teardown()
|
||||
_state.value = SignerState.Connecting(pairing.relay)
|
||||
val opened = openSession(
|
||||
pairing.relay, pairing.clientPubkey, pairing.name,
|
||||
secret = "", signerSecretHex = secret, info = info,
|
||||
)
|
||||
if (opened == null) {
|
||||
_state.value = SignerState.Failed("relay ${pairing.relay} did not answer")
|
||||
return@withContext "Could not reach ${pairing.relay}"
|
||||
}
|
||||
session.set(opened)
|
||||
_state.value = SignerState.AwaitingClient(pairing.relay, pairing.name)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun unpair() {
|
||||
teardown()
|
||||
_state.value = SignerState.Idle
|
||||
}
|
||||
|
||||
private fun teardown() {
|
||||
session.getAndSet(null)?.socket?.close(1000, "unpaired")
|
||||
_pending.value = null
|
||||
}
|
||||
|
||||
private fun randomId(): String {
|
||||
val bytes = ByteArray(8)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun openSession(
|
||||
relay: String,
|
||||
clientPubkey: String,
|
||||
clientName: String,
|
||||
secret: String,
|
||||
signerSecretHex: String,
|
||||
info: JSONObject,
|
||||
): Session? {
|
||||
val signerPubkeyHex = info.getString("pubkey")
|
||||
val connectRequestId = randomId()
|
||||
// The listener needs the Session, the Session needs the WebSocket:
|
||||
// bind through a holder set right after newWebSocket returns (OkHttp
|
||||
// invokes onOpen on its own dispatcher after the network round-trip,
|
||||
// i.e. always after the bind below).
|
||||
val holder = AtomicReference<Session?>()
|
||||
|
||||
val request = Request.Builder().url(relay).build()
|
||||
val socket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
val s = holder.get() ?: return
|
||||
// Subscribe to requests addressed to us (p-tag filter), from
|
||||
// now — no history replay of stale login attempts.
|
||||
webSocket.send(
|
||||
"""["REQ","${s.connectRequestId}sub",{"kinds":[24133],"#p":["${s.signerPubkeyHex}"],"since":${epochSecs() - 120}}]"""
|
||||
)
|
||||
// Handshake: the signer sends `connect` carrying the secret
|
||||
// (rust-nostr's NostrConnectRemoteSigner.send_connect_ack —
|
||||
// the exact frame the node's client waits for).
|
||||
val content = JSONObject().apply {
|
||||
put("id", s.connectRequestId)
|
||||
put("method", "connect")
|
||||
put("params", JSONArray().put(s.signerPubkeyHex).put(s.secret))
|
||||
}.toString()
|
||||
if (!sendEncrypted(s, content)) {
|
||||
_state.value = SignerState.Failed("Could not encrypt the connect message")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val s = session.get() ?: return
|
||||
handleRelayMessage(s, text)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (session.get()?.socket === webSocket) {
|
||||
_state.value = SignerState.Failed(t.message ?: "relay connection failed")
|
||||
session.getAndSet(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (session.get()?.socket === webSocket) {
|
||||
_state.value = SignerState.Idle
|
||||
session.getAndSet(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val s = Session(
|
||||
socket = socket,
|
||||
relay = relay,
|
||||
clientPubkey = clientPubkey,
|
||||
clientName = clientName,
|
||||
secret = secret,
|
||||
connectRequestId = connectRequestId,
|
||||
signerSecretHex = signerSecretHex,
|
||||
signerPubkeyHex = signerPubkeyHex,
|
||||
)
|
||||
holder.set(s)
|
||||
return s
|
||||
}
|
||||
|
||||
private fun epochSecs(): Long = System.currentTimeMillis() / 1000
|
||||
|
||||
/** Encrypt a JSON-RPC frame to the peer and publish it as kind 24133. */
|
||||
private fun sendEncrypted(s: Session, json: String): Boolean {
|
||||
val enc = NativeCore.nostrNip44Encrypt(s.signerSecretHex, s.clientPubkey, json)
|
||||
if (NativeCore.isErr(enc)) return false
|
||||
val payload = JSONObject(enc).getString("result")
|
||||
val event = JSONObject().apply {
|
||||
put("kind", 24133)
|
||||
put("content", payload)
|
||||
put("tags", JSONArray().put(JSONArray().put("p").put(s.clientPubkey)))
|
||||
put("created_at", epochSecs())
|
||||
}.toString()
|
||||
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, event)
|
||||
if (NativeCore.isErr(signed)) return false
|
||||
return s.socket.send("""["EVENT",$signed]""")
|
||||
}
|
||||
|
||||
private fun handleRelayMessage(s: Session, text: String) {
|
||||
val arr = try {
|
||||
JSONArray(text)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
if (arr.length() == 0) return
|
||||
when (arr.optString(0)) {
|
||||
"EVENT" -> {
|
||||
val event = arr.optJSONObject(2) ?: return
|
||||
if (event.optLong("kind") != 24133L) return
|
||||
val id = event.optString("id")
|
||||
if (id.isNotEmpty() && !s.seen.add(id)) return
|
||||
val author = event.optString("pubkey")
|
||||
if (author != s.clientPubkey) return // not our client
|
||||
handleClientEvent(s, author, event.optString("content"))
|
||||
}
|
||||
// OK / CLOSED / NOTICE: nothing actionable for the bunker in v1.
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClientEvent(s: Session, author: String, content: String) {
|
||||
// NIP-44 is the mandated transport; NIP-04 stays as receive fallback
|
||||
// for clients that still speak the deprecated scheme.
|
||||
val plain = run {
|
||||
val nip44 = NativeCore.nostrNip44Decrypt(s.signerSecretHex, author, content)
|
||||
if (!NativeCore.isErr(nip44)) JSONObject(nip44).getString("result") else {
|
||||
val nip04 = NativeCore.nostrNip04Decrypt(s.signerSecretHex, author, content)
|
||||
if (!NativeCore.isErr(nip04)) JSONObject(nip04).getString("result") else return
|
||||
}
|
||||
}
|
||||
|
||||
val msg = try {
|
||||
JSONObject(plain)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
|
||||
val id = msg.optString("id")
|
||||
val method = msg.optString("method", "")
|
||||
if (method.isNotEmpty()) {
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
val params = msg.optJSONArray("params") ?: return
|
||||
// Param 0 must be OUR pubkey (client is connecting to us,
|
||||
// not some other bunker through this session).
|
||||
val target = params.optString(0)
|
||||
val givenSecret = params.optString(1)
|
||||
val authorized = target == s.signerPubkeyHex &&
|
||||
(s.secret.isBlank() || givenSecret == s.secret || givenSecret.isBlank())
|
||||
if (authorized) {
|
||||
respond(s, id, result = "ack")
|
||||
_state.value = SignerState.Ready(s.relay, s.clientName)
|
||||
} else {
|
||||
respond(s, id, error = "unauthorized")
|
||||
}
|
||||
}
|
||||
"get_public_key" -> respond(s, id, result = s.signerPubkeyHex)
|
||||
"describe" -> respond(s, id, result = "connect get_public_key sign_event ping")
|
||||
"ping" -> respond(s, id, result = "pong")
|
||||
"sign_event" -> {
|
||||
val params = msg.optJSONArray("params") ?: return
|
||||
val eventJson = params.optString(0)
|
||||
val ev = try {
|
||||
JSONObject(eventJson)
|
||||
} catch (_: Exception) {
|
||||
respond(s, id, error = "malformed event")
|
||||
return
|
||||
}
|
||||
// Never overwrite a pending request silently — a second
|
||||
// tap on the node would otherwise cancel the visible one.
|
||||
if (_pending.value == null) {
|
||||
_pending.value = PendingRequest(
|
||||
id = id,
|
||||
method = method,
|
||||
clientPubkey = author,
|
||||
clientName = s.clientName,
|
||||
kind = if (ev.has("kind") && !ev.isNull("kind")) ev.optLong("kind") else null,
|
||||
content = if (ev.has("content") && !ev.isNull("content")) ev.optString("content") else null,
|
||||
tags = formatTags(ev.optJSONArray("tags")),
|
||||
createdAt = if (ev.has("created_at") && !ev.isNull("created_at")) ev.optLong("created_at") else null,
|
||||
unsignedEventJson = eventJson,
|
||||
)
|
||||
} else {
|
||||
respond(s, id, error = "busy")
|
||||
}
|
||||
}
|
||||
else -> respond(s, id, error = "not authorized")
|
||||
}
|
||||
} else if (msg.has("result") || msg.has("error")) {
|
||||
// A response to OUR connect request (the client's ack).
|
||||
if (id == s.connectRequestId) {
|
||||
if (msg.has("error")) {
|
||||
_state.value = SignerState.Failed("Client rejected the connection: ${msg.optString("error")}")
|
||||
} else if (msg.optString("result") == "ack") {
|
||||
_state.value = SignerState.Ready(s.relay, s.clientName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Approve the pending request: sign and send the result. */
|
||||
suspend fun approve(): Boolean {
|
||||
val s = session.get() ?: return false
|
||||
val req = _pending.value ?: return false
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
val signed = NativeCore.nostrSignEvent(s.signerSecretHex, req.unsignedEventJson)
|
||||
if (NativeCore.isErr(signed)) {
|
||||
respond(s, req.id, error = "signing failed")
|
||||
false
|
||||
} else {
|
||||
// Result is the signed event, JSON-stringified per the spec.
|
||||
respond(s, req.id, result = signed)
|
||||
}
|
||||
}
|
||||
_pending.value = null
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Deny the pending request with an explicit error. */
|
||||
fun deny() {
|
||||
val s = session.get() ?: return
|
||||
val req = _pending.value ?: return
|
||||
respond(s, req.id, error = "denied")
|
||||
_pending.value = null
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC response frame to the client. True when the WS send worked. */
|
||||
private fun respond(s: Session, id: String, result: String? = null, error: String? = null): Boolean {
|
||||
val frame = JSONObject().apply {
|
||||
put("id", id)
|
||||
if (error != null) put("error", error)
|
||||
if (result != null) put("result", result)
|
||||
}.toString()
|
||||
return sendEncrypted(s, frame)
|
||||
}
|
||||
|
||||
private fun formatTags(tags: JSONArray?): List<String> {
|
||||
tags ?: return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
for (i in 0 until tags.length()) {
|
||||
val tag = tags.optJSONArray(i) ?: continue
|
||||
val parts = mutableListOf<String>()
|
||||
for (j in 0 until tag.length()) parts.add(tag.optString(j))
|
||||
out.add(parts.joinToString(" "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.archipelago.app.nostr
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.archipelago.app.NativeCore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
private val Context.signerDataStore: DataStore<Preferences> by preferencesDataStore(name = "nostr_signer")
|
||||
|
||||
/**
|
||||
* Storage for the phone-side NIP-46 remote signer (#139): the signer secret
|
||||
* key (hex) and the last pairing, so a re-opened app can resume a session
|
||||
* without re-scanning the node's QR.
|
||||
*
|
||||
* Same plaintext-DataStore model as the FIPS secret (app-private storage,
|
||||
* no extra OS keystore ceremony — the node login password lives the same way
|
||||
* in ServerPreferences); the nsec grants the ability to sign as this identity,
|
||||
* never node login.
|
||||
*/
|
||||
class NostrSignerPreferences(private val context: Context) {
|
||||
|
||||
private val secretKey = stringPreferencesKey("signer_secret")
|
||||
private val clientPubkeyKey = stringPreferencesKey("pair_client_pubkey")
|
||||
private val clientRelayKey = stringPreferencesKey("pair_client_relay")
|
||||
private val clientNameKey = stringPreferencesKey("pair_client_name")
|
||||
|
||||
/** The signer secret (hex) or null when no key exists yet. */
|
||||
suspend fun secret(): String? = context.signerDataStore.data.first()[secretKey]
|
||||
|
||||
val secretFlow: Flow<String?> = context.signerDataStore.data
|
||||
.map { it[secretKey] }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun saveSecret(hex: String) {
|
||||
context.signerDataStore.edit { it[secretKey] = hex.trim() }
|
||||
}
|
||||
|
||||
/** Generate a fresh signer key (fails if the native core is missing). */
|
||||
suspend fun generateSecret(): JSONObject = withContext(Dispatchers.IO) {
|
||||
val json = NativeCore.nostrGenerateSecret()
|
||||
val obj = JSONObject(json)
|
||||
if (obj.has("error")) throw IllegalStateException(obj.getString("error"))
|
||||
saveSecret(obj.getString("secret"))
|
||||
obj
|
||||
}
|
||||
|
||||
/** Import a secret from hex or nsec…; returns the parsed key info. */
|
||||
suspend fun importSecret(raw: String): JSONObject = withContext(Dispatchers.IO) {
|
||||
val json = NativeCore.nostrSecretFromAny(raw.trim())
|
||||
val obj = JSONObject(json)
|
||||
if (obj.has("error")) throw IllegalArgumentException(obj.getString("error"))
|
||||
saveSecret(obj.getString("secret"))
|
||||
obj
|
||||
}
|
||||
|
||||
data class Pairing(val clientPubkey: String, val relay: String, val name: String)
|
||||
|
||||
suspend fun lastPairing(): Pairing? {
|
||||
val prefs = context.signerDataStore.data.first()
|
||||
val pubkey = prefs[clientPubkeyKey] ?: return null
|
||||
val relay = prefs[clientRelayKey] ?: return null
|
||||
if (pubkey.isBlank() || relay.isBlank()) return null
|
||||
return Pairing(pubkey, relay, prefs[clientNameKey] ?: "")
|
||||
}
|
||||
|
||||
suspend fun savePairing(pairing: Pairing) {
|
||||
context.signerDataStore.edit {
|
||||
it[clientPubkeyKey] = pairing.clientPubkey
|
||||
it[clientRelayKey] = pairing.relay
|
||||
it[clientNameKey] = pairing.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearPairing() {
|
||||
context.signerDataStore.edit {
|
||||
it.remove(clientPubkeyKey)
|
||||
it.remove(clientRelayKey)
|
||||
it.remove(clientNameKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun wipeKey() {
|
||||
context.signerDataStore.edit { it.remove(secretKey) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.data.BackupManager
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Backup & Restore (#128) — the hub's BACKUP sub-page (same container as
|
||||
* Nodes/FIPS), the phone side of losing your phone or wiping it to cross a
|
||||
* border. See docs/companion-backup-restore.md for the envelope and merge
|
||||
* semantics; this composable is the flow only.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupSection() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val manager = remember { BackupManager(context) }
|
||||
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
var confirm by remember { mutableStateOf("") }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
var statusError by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
// Decrypted backup awaiting the user's go-ahead (restore flow).
|
||||
var restorePreview by remember { mutableStateOf<Pair<BackupManager.PayloadSummary, org.json.JSONObject>?>(null) }
|
||||
|
||||
fun say(msg: String, error: Boolean) {
|
||||
status = msg
|
||||
statusError = error
|
||||
}
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val envelope = manager.createBackup(passphrase)
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
out.write(envelope.toByteArray())
|
||||
} ?: throw BackupManager.BackupException("could not open the destination file")
|
||||
}
|
||||
say("Saved — keep the file and the passphrase somewhere safe.", false)
|
||||
} catch (e: Exception) {
|
||||
say(e.message ?: "backup failed", true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val envelope = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
|
||||
?: throw BackupManager.BackupException("could not read the selected file")
|
||||
}
|
||||
val (summary, payload) = manager.readBackup(envelope, passphrase)
|
||||
restorePreview = summary to payload
|
||||
} catch (e: Exception) {
|
||||
say(e.message ?: "restore failed", true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionCopy(
|
||||
"An encrypted copy of everything this phone holds — nodes and their passwords, " +
|
||||
"your mesh identity, the remote-signer key. Same envelope your node uses (ADR-005), " +
|
||||
"one passphrase, no cloud."
|
||||
)
|
||||
|
||||
// ── Create a backup ──────────────────────────────────────────────
|
||||
SectionHeader(Icons.Default.Save, "Create a backup")
|
||||
GlassField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
placeholder = "Passphrase",
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||
)
|
||||
GlassField(
|
||||
value = confirm,
|
||||
onValueChange = { confirm = it },
|
||||
placeholder = "Repeat passphrase",
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||
)
|
||||
SectionHint("The passphrase cannot be recovered — a backup nobody can open is a paperweight.")
|
||||
WideAction(
|
||||
text = if (busy) "Working…" else "Save backup file",
|
||||
onClick = {
|
||||
if (busy) return@WideAction
|
||||
if (passphrase.length < 8) {
|
||||
say("Use at least 8 characters — this passphrase guards every secret in the app.", true)
|
||||
return@WideAction
|
||||
}
|
||||
if (passphrase != confirm) {
|
||||
say("The two passphrases don't match.", true)
|
||||
return@WideAction
|
||||
}
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date())
|
||||
exportLauncher.launch("archy-companion-backup-$stamp.json")
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// ── Restore a backup ─────────────────────────────────────────────
|
||||
SectionHeader(Icons.Default.Restore, "Restore a backup")
|
||||
SectionHint(
|
||||
"Nothing is overwritten: nodes merge by identity, and the mesh identity and " +
|
||||
"signer key only restore when this phone has none."
|
||||
)
|
||||
WideAction(
|
||||
text = if (busy) "Working…" else "Choose backup file",
|
||||
onClick = {
|
||||
if (busy) return@WideAction
|
||||
if (passphrase.isEmpty()) {
|
||||
say("Enter the backup's passphrase first.", true)
|
||||
return@WideAction
|
||||
}
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
},
|
||||
)
|
||||
|
||||
restorePreview?.let { (summary, payload) ->
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.04f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
"Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}",
|
||||
color = SuccessGreen, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
SummaryRow("Nodes", summary.serverCount.toString())
|
||||
if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included")
|
||||
if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included")
|
||||
WideAction(
|
||||
text = if (busy) "Restoring…" else "Restore onto this phone",
|
||||
onClick = {
|
||||
if (busy) return@WideAction
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val result = manager.restoreBackup(payload)
|
||||
restorePreview = null
|
||||
passphrase = ""
|
||||
confirm = ""
|
||||
say(
|
||||
"Restored ${result.serversRestored} node(s)" +
|
||||
(if (result.activeSet) ", set active" else "") +
|
||||
(if (result.fipsIdentityRestored) ", mesh identity" else "") +
|
||||
(if (result.signerKeyRestored) ", signer key" else "") +
|
||||
". Restart the app to reconnect.",
|
||||
false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
say(e.message ?: "restore failed", true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
status?.takeIf { it.isNotBlank() }?.let { msg ->
|
||||
Text(
|
||||
msg,
|
||||
color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionHeader(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp))
|
||||
Text(title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionCopy(text: String) {
|
||||
Text(text, color = TextMuted, fontSize = 12.sp, lineHeight = 16.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SectionHint(text: String) {
|
||||
Text(text, color = TextMuted.copy(alpha = 0.8f), fontSize = 10.sp, lineHeight = 13.sp)
|
||||
}
|
||||
|
||||
/** Wide orange-outline action button in the menu's visual language. */
|
||||
@Composable
|
||||
internal fun WideAction(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { onClick() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
Text(text, color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = TextMuted, fontSize = 12.sp)
|
||||
Text(value, color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.SettingsBackupRestore
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -106,22 +108,62 @@ fun NESMenu(
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
// Remote-signer pairing request (nostrconnect://… deep link, or a scan):
|
||||
// non-null opens the hub on the signer sub-page and pairs. Consumed once
|
||||
// the signer section hands it back via [onSignerPairHandled].
|
||||
signerPairRequest: String? = null,
|
||||
onSignerPairHandled: () -> Unit = {},
|
||||
) {
|
||||
// Pairing state is latched here (not passed straight through) so the
|
||||
// source can clear itself while the request stays alive until consumed.
|
||||
var pendingSignerPair by remember { mutableStateOf<String?>(null) }
|
||||
var signerScan by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(signerPairRequest) {
|
||||
if (signerPairRequest != null) pendingSignerPair = signerPairRequest
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
||||
// inside its own bounds when content is tall. Tapping the dimmed
|
||||
// backdrop dismisses.
|
||||
// holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer)
|
||||
// and scrolls inside its own bounds when content is tall. Tapping the
|
||||
// dimmed backdrop dismisses.
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||
MenuPanel(
|
||||
servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr,
|
||||
onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty,
|
||||
signerPairUri = pendingSignerPair,
|
||||
onSignerScan = { signerScan = true },
|
||||
onSignerPairHandled = {
|
||||
pendingSignerPair = null
|
||||
onSignerPairHandled()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing-QR scanner for the signer sub-page — a full-screen glass
|
||||
// modal hosted OUTSIDE the hub panel so it isn't clipped to the panel's
|
||||
// bounds (same layering the pairing scanner gets from WebViewScreen).
|
||||
QrGlassModal(
|
||||
visible = signerScan && visible,
|
||||
title = "Scan pairing QR",
|
||||
status = null,
|
||||
idleHint = "Point at the nostrconnect QR the node or client shows",
|
||||
permissionRationale = "Camera access is needed to scan the pairing code",
|
||||
onDismiss = { signerScan = false },
|
||||
onDecoded = { text ->
|
||||
if (text.startsWith("nostrconnect://")) {
|
||||
signerScan = false
|
||||
pendingSignerPair = text
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -138,6 +180,9 @@ private fun MenuPanel(
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
onMeshParty: (() -> Unit)?,
|
||||
signerPairUri: String?,
|
||||
onSignerScan: () -> Unit,
|
||||
onSignerPairHandled: () -> Unit,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
// The saved server being edited, or null when adding a new one.
|
||||
@@ -176,9 +221,10 @@ private fun MenuPanel(
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
// Cap height just short of the full screen; the panel wraps short
|
||||
// content and only scrolls in the rare case it outgrows this.
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
||||
// Cap height at 70% of the screen — a ~15% breathing margin top
|
||||
// and bottom — the panel wraps short content and scrolls inside
|
||||
// its own bounds when a sub-page outgrows this.
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg.copy(alpha = 0.86f))
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
@@ -201,7 +247,13 @@ private fun MenuPanel(
|
||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||
when (page) {
|
||||
HubPage.NODES -> "Nodes"
|
||||
HubPage.FIPS -> "FIPS Mesh"
|
||||
HubPage.BACKUP -> "Backup & Restore"
|
||||
HubPage.SIGNER -> "Remote Signer"
|
||||
HubPage.HUB -> "Menu"
|
||||
},
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
)
|
||||
}
|
||||
@@ -233,6 +285,12 @@ private fun MenuPanel(
|
||||
if (onMeshParty != null) {
|
||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||
}
|
||||
// Backup & Restore (#128): the phone side of losing your phone
|
||||
// or wiping it to cross a border — encrypted export file, no cloud.
|
||||
HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { page = HubPage.BACKUP }
|
||||
// Remote Signer (#139): hold a nostr key on the phone and
|
||||
// approve/deny remote signature requests (NIP-46).
|
||||
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { page = HubPage.SIGNER }
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
|
||||
@@ -272,6 +330,11 @@ private fun MenuPanel(
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = server.displayName(),
|
||||
// FIPS nodes carry their mesh ULA — the address Termux
|
||||
// (or any other app) can reach over the split-tunnel,
|
||||
// from anywhere. Tap to copy; the node's npub stays
|
||||
// visible in the FIPS Mesh page.
|
||||
subtitle = server.meshIp.takeIf { it.isNotBlank() },
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onEdit = { startEdit(server) },
|
||||
@@ -391,11 +454,23 @@ private fun MenuPanel(
|
||||
HubPage.FIPS -> {
|
||||
FipsSection(embedded = true)
|
||||
}
|
||||
|
||||
HubPage.BACKUP -> {
|
||||
BackupSection()
|
||||
}
|
||||
|
||||
HubPage.SIGNER -> {
|
||||
SignerSection(
|
||||
pairUri = signerPairUri,
|
||||
onScan = onSignerScan,
|
||||
onPairHandled = onSignerPairHandled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class HubPage { HUB, NODES, FIPS }
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
|
||||
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
@@ -582,26 +657,52 @@ private fun MenuItem(
|
||||
onClick: () -> Unit,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
/** Optional second line (the node's mesh ULA); tapping it copies. */
|
||||
subtitle: String? = null,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ROW_H)
|
||||
// Rows with a second line grow to fit it.
|
||||
.then(if (subtitle == null) Modifier.height(ROW_H) else Modifier.heightIn(min = 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 = 16.dp),
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(if (subtitle == null) Modifier else Modifier.padding(vertical = 8.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 2.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { clipboard.setText(AnnotatedString(subtitle)) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
subtitle,
|
||||
color = TextMuted,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("⧉", color = TextMuted.copy(alpha = 0.7f), fontSize = 11.sp, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
Text(
|
||||
"✎",
|
||||
@@ -623,7 +724,7 @@ private fun MenuItem(
|
||||
|
||||
/** Glass text field with centered input text. */
|
||||
@Composable
|
||||
private fun GlassField(
|
||||
internal fun GlassField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Cross-layer handoff for remote-signer pairing (#139): NavGraph's
|
||||
* `nostrconnect://` deep link drops the URI here and routes to the session;
|
||||
* WebViewScreen collects it, opens the hub menu, and NESMenu opens the
|
||||
* signer sub-page with the request. Cleared once the signer section has
|
||||
* consumed it (via NESMenu's onSignerPairHandled).
|
||||
*/
|
||||
object SignerLaunch {
|
||||
val pendingUri = MutableStateFlow<String?>(null)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.NativeCore
|
||||
import com.archipelago.app.nostr.BunkerManager
|
||||
import com.archipelago.app.nostr.NostrSignerPreferences
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Remote Signer (#139) — the hub's SIGNER sub-page (same container as
|
||||
* Nodes/FIPS). The phone holds a nostr key; a NIP-46 client (the node's
|
||||
* login QR, any nostrconnect:// app) pairs via [pairUri] or the scanner
|
||||
* (hosted by NESMenu outside this panel), and every `sign_event` request
|
||||
* lands as a legible approve/deny card. See
|
||||
* docs/companion-nip46-remote-signer.md.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SignerSection(
|
||||
pairUri: String?,
|
||||
onScan: () -> Unit,
|
||||
onPairHandled: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val prefs = remember { NostrSignerPreferences(context) }
|
||||
|
||||
var keyInfo by remember { mutableStateOf<JSONObject?>(null) }
|
||||
var keyError by remember { mutableStateOf<String?>(null) }
|
||||
var importText by remember { mutableStateOf("") }
|
||||
var showNsec by remember { mutableStateOf(false) }
|
||||
var notice by remember { mutableStateOf<String?>(null) }
|
||||
var noticeError by remember { mutableStateOf(false) }
|
||||
|
||||
val bunkerState by BunkerManager.state.collectAsState()
|
||||
val pending by BunkerManager.pending.collectAsState()
|
||||
|
||||
fun say(msg: String, error: Boolean) {
|
||||
notice = msg
|
||||
noticeError = error
|
||||
}
|
||||
|
||||
suspend fun loadKey() {
|
||||
val secret = prefs.secret()
|
||||
keyInfo = secret?.let {
|
||||
val json = NativeCore.nostrSecretFromAny(it)
|
||||
if (NativeCore.isErr(json)) null else JSONObject(json)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
BunkerManager.refreshState(context)
|
||||
loadKey()
|
||||
}
|
||||
|
||||
// Consume a pairing request (deep link or scanner) exactly once.
|
||||
LaunchedEffect(pairUri) {
|
||||
val uri = pairUri?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||
if (keyInfo == null) loadKey()
|
||||
val err = BunkerManager.pair(context, uri)
|
||||
if (err != null) say(err, true) else say("Pairing started…", false)
|
||||
onPairHandled()
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SectionCopy(
|
||||
"Hold a nostr key on this phone and sign for it remotely — pair with your " +
|
||||
"node's login QR (or any NIP-46 client), then approve each signature " +
|
||||
"request as it arrives. Nothing signs without you."
|
||||
)
|
||||
|
||||
if (bunkerState is BunkerManager.SignerState.Unavailable) {
|
||||
Text(
|
||||
"Signing is unavailable on this device (native core missing).",
|
||||
color = Color(0xFFFF6B6B), fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
val info = keyInfo
|
||||
if (info == null) {
|
||||
// ── No key yet: generate or import ──────────────────────────
|
||||
keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 11.sp) }
|
||||
WideAction(text = "Generate signer key", onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
keyInfo = prefs.generateSecret()
|
||||
keyError = null
|
||||
BunkerManager.refreshState(context)
|
||||
} catch (e: Exception) {
|
||||
keyError = e.message ?: "could not generate a key"
|
||||
}
|
||||
}
|
||||
})
|
||||
GlassField(
|
||||
value = importText,
|
||||
onValueChange = { importText = it },
|
||||
placeholder = "or import nsec…",
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (importText.isNotBlank()) {
|
||||
scope.launch {
|
||||
try {
|
||||
keyInfo = prefs.importSecret(importText)
|
||||
importText = ""
|
||||
keyError = null
|
||||
BunkerManager.refreshState(context)
|
||||
} catch (e: Exception) {
|
||||
keyError = e.message ?: "not a valid nsec"
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
WideAction(text = "Import", onClick = {
|
||||
if (importText.isBlank()) return@WideAction
|
||||
scope.launch {
|
||||
try {
|
||||
keyInfo = prefs.importSecret(importText)
|
||||
importText = ""
|
||||
keyError = null
|
||||
BunkerManager.refreshState(context)
|
||||
} catch (e: Exception) {
|
||||
keyError = e.message ?: "not a valid nsec"
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// ── Identity ─────────────────────────────────────────────────
|
||||
SectionHeader(Icons.Default.Key, "Signer identity")
|
||||
MonoValue("npub", info.optString("npub")) {
|
||||
clipboard.setText(AnnotatedString(info.optString("npub")))
|
||||
}
|
||||
if (showNsec) {
|
||||
MonoValue("nsec", info.optString("nsec"), secret = true) {
|
||||
clipboard.setText(AnnotatedString(info.optString("nsec")))
|
||||
}
|
||||
SectionHint("Anyone with the nsec can sign as you — clear the clipboard after copying.")
|
||||
} else {
|
||||
Text(
|
||||
"Show nsec",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { showNsec = true }
|
||||
.padding(vertical = 2.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val label = when (val s = bunkerState) {
|
||||
BunkerManager.SignerState.Unavailable -> "Unavailable on this device"
|
||||
BunkerManager.SignerState.NoKey -> "No signer key yet"
|
||||
BunkerManager.SignerState.Idle -> "Idle — pair to start"
|
||||
is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…"
|
||||
is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for the handshake to finish"
|
||||
is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\""
|
||||
is BunkerManager.SignerState.Failed -> s.reason
|
||||
}
|
||||
Text("Session", color = TextMuted, fontSize = 11.sp)
|
||||
Text(
|
||||
label,
|
||||
color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B)
|
||||
else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen
|
||||
else TextPrimary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 17.sp,
|
||||
)
|
||||
WideAction(
|
||||
text = "Scan pairing QR",
|
||||
onClick = {
|
||||
if (bunkerState is BunkerManager.SignerState.NoKey) {
|
||||
say("Generate or import a signer key first.", true)
|
||||
return@WideAction
|
||||
}
|
||||
onScan()
|
||||
},
|
||||
icon = Icons.Default.QrCodeScanner,
|
||||
)
|
||||
if (bunkerState is BunkerManager.SignerState.Ready ||
|
||||
bunkerState is BunkerManager.SignerState.AwaitingClient ||
|
||||
bunkerState is BunkerManager.SignerState.Connecting
|
||||
) {
|
||||
Text(
|
||||
"End session",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { BunkerManager.unpair() }
|
||||
.padding(vertical = 2.dp, horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pending signature request — the whole point ──────────────
|
||||
pending?.let { req ->
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.04f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.35f), RoundedCornerShape(14.dp))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("Signature request", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
SummaryRow("Client", req.clientName.ifBlank { req.clientPubkey.take(12) + "…" })
|
||||
SummaryRow("Kind", kindLabel(req.kind))
|
||||
req.createdAt?.let {
|
||||
SummaryRow("Time", SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000)))
|
||||
}
|
||||
req.content?.takeIf { it.isNotBlank() }?.let { content ->
|
||||
Text(
|
||||
content,
|
||||
color = TextPrimary, fontSize = 10.sp, lineHeight = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.padding(8.dp)
|
||||
.heightIn(max = 160.dp),
|
||||
)
|
||||
}
|
||||
if (req.tags.isNotEmpty()) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
req.tags.take(6).forEach {
|
||||
Text(
|
||||
it,
|
||||
color = TextMuted, fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (req.tags.size > 6) {
|
||||
Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 9.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFFE5484D).copy(alpha = 0.16f))
|
||||
.border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||
.clickable { BunkerManager.deny() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 13.sp, fontWeight = FontWeight.Bold) }
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BitcoinOrange.copy(alpha = 0.2f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val ok = BunkerManager.approve()
|
||||
say(if (ok) "Signed and sent." else "Could not send the signature.", !ok)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Approve", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notice?.takeIf { it.isNotBlank() }?.let { msg ->
|
||||
Text(
|
||||
msg,
|
||||
color = if (noticeError) Color(0xFFFF6B6B) else SuccessGreen,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kind number → legible label, so the approve/deny card reads like a sentence. */
|
||||
private fun kindLabel(kind: Long?): String = when (kind) {
|
||||
0L -> "Metadata (kind 0)"
|
||||
1L -> "Text note (kind 1)"
|
||||
3L -> "Contact list (kind 3)"
|
||||
4L -> "Direct message (kind 4)"
|
||||
7L -> "Reaction (kind 7)"
|
||||
14L -> "Chat message (kind 14)"
|
||||
22242L -> "Client authentication (kind 22242)"
|
||||
30078L -> "App-stored data (kind 30078)"
|
||||
null -> "Unknown kind"
|
||||
else -> "Kind $kind"
|
||||
}
|
||||
|
||||
/** Monospace value chip with a copy affordance (tap the row). */
|
||||
@Composable
|
||||
private fun MonoValue(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(label, color = TextMuted, fontSize = 10.sp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { onCopy() }
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
color = if (secret) Color(0xFFFFB86B) else TextPrimary,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.SignerLaunch
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||
@@ -133,27 +134,41 @@ fun AppNavHost(
|
||||
LaunchedEffect(pairUri) {
|
||||
val raw = pairUri ?: return@LaunchedEffect
|
||||
onPairUriConsumed()
|
||||
when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
when {
|
||||
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
||||
// from the node's login QR — any QR scanner app can hand it over.
|
||||
// The signer UI lives inside the hub menu: drop the URI where
|
||||
// WebViewScreen picks it up and route to the session, which opens
|
||||
// the hub on its signer sub-page.
|
||||
raw.startsWith("nostrconnect://") -> {
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
pairPrefill = merged
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
SignerLaunch.pendingUri.value = raw
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
else -> when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
pairPrefill = merged
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.NESMenu
|
||||
import com.archipelago.app.ui.components.SignerLaunch
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.components.SlidingLoader
|
||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||
@@ -1373,6 +1374,16 @@ fun WebViewScreen(
|
||||
// Hub menu overlay — opened by the three-finger hold, drawn above
|
||||
// everything (also reachable from the error screen, where switching
|
||||
// servers is exactly what's needed).
|
||||
// Remote-signer deep link: route to the session and pop the hub open
|
||||
// on its signer sub-page (the request itself is consumed by NESMenu).
|
||||
var signerPairRequest by remember { mutableStateOf<String?>(null) }
|
||||
val signerLaunch by SignerLaunch.pendingUri.collectAsState()
|
||||
LaunchedEffect(signerLaunch) {
|
||||
val uri = signerLaunch ?: return@LaunchedEffect
|
||||
signerPairRequest = uri
|
||||
SignerLaunch.pendingUri.value = null
|
||||
showHubMenu = true
|
||||
}
|
||||
NESMenu(
|
||||
visible = showHubMenu,
|
||||
servers = savedServers,
|
||||
@@ -1427,6 +1438,8 @@ fun WebViewScreen(
|
||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||
onBackToWebView = { showHubMenu = false },
|
||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||
signerPairRequest = signerPairRequest,
|
||||
onSignerPairHandled = { signerPairRequest = null },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||
|
||||
Generated
+369
-1
@@ -12,6 +12,17 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -81,17 +92,41 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
name = "archy-fips-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"bech32",
|
||||
"cbc",
|
||||
"chacha20 0.9.1",
|
||||
"chacha20poly1305",
|
||||
"fips",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"jni",
|
||||
"libc",
|
||||
"paranoid-android",
|
||||
"secp256k1 0.29.1",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -124,6 +159,18 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bech32"
|
||||
version = "0.11.1"
|
||||
@@ -172,6 +219,15 @@ version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -181,6 +237,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
@@ -200,6 +265,15 @@ version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.3.0"
|
||||
@@ -406,6 +480,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
@@ -476,7 +561,7 @@ dependencies = [
|
||||
"libc",
|
||||
"rand 0.10.2",
|
||||
"rtnetlink",
|
||||
"secp256k1",
|
||||
"secp256k1 0.30.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -491,6 +576,15 @@ dependencies = [
|
||||
"tun",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
@@ -676,6 +770,110 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -692,6 +890,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -788,6 +987,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
@@ -954,12 +1159,29 @@ version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -988,6 +1210,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1129,6 +1360,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"secp256k1-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.30.0"
|
||||
@@ -1272,6 +1512,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -1306,6 +1552,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1355,6 +1612,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -1519,6 +1786,24 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@@ -1657,6 +1942,35 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
@@ -1677,12 +1991,66 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -37,6 +37,35 @@ tracing = "0.1"
|
||||
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
|
||||
libc = "0.2"
|
||||
|
||||
# ── Companion backup (#128) ───────────────────────────────────────────────
|
||||
# ADR-005 envelope: the SAME crates and blob layout as the node's backup code
|
||||
# (core/archipelago/src/backup/identity.rs) — Argon2id KDF + ChaCha20-Poly1305
|
||||
# AEAD — applied to the companion's own JSON payload. Do not diverge from
|
||||
# those parameters: a companion backup and a node backup must decrypt with
|
||||
# the same code path on either side.
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# ── NIP-46 remote signer (#139) ───────────────────────────────────────────
|
||||
# BIP340 schnorr signing + secp256k1 ECDH (NIP-44/NIP-04 conversation keys).
|
||||
# Audited libsecp256k1 via cc; cargo-ndk provides the NDK clang on Android.
|
||||
secp256k1 = "0.29"
|
||||
# NIP-44 v2: HKDF-SHA256 (conversation/message keys) + HMAC-SHA256 (MAC).
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
hkdf = "0.12"
|
||||
# NIP-44 v2 stream cipher (raw ChaCha20, RFC 8439 — NOT the AEAD).
|
||||
chacha20 = "0.9"
|
||||
# NIP-04 fallback (deprecated in the spec but still sent by real clients):
|
||||
# AES-256-CBC, key = raw ECDH x-coordinate.
|
||||
aes = "0.8"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
# npub/nsec (bech32, BIP173 variant — NOT Bech32m).
|
||||
bech32 = "0.11"
|
||||
# nostrconnect:// URI parsing (repeated relay params + percent-decoding).
|
||||
url = "2.5"
|
||||
|
||||
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||
# mesh module directly (tests).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Companion app backup — the ADR-005 encrypted-backup envelope.
|
||||
//!
|
||||
//! Reuses the node's backup format exactly (ADR-005:
|
||||
//! `core/archipelago/src/backup/identity.rs`): Argon2id key derivation with
|
||||
//! default params, ChaCha20-Poly1305 AEAD, and the same blob layout
|
||||
//! `base64(salt[16] || nonce[12] || ciphertext)`. A companion backup and a
|
||||
//! node backup share one crypto story — the payload differs (the companion
|
||||
//! serializes its servers, FIPS identity and signer key instead of a node
|
||||
//! key), the envelope does not.
|
||||
//!
|
||||
//! The envelope is JSON with `version`, `kind`, `encrypted`, `blob` and
|
||||
//! `timestamp`; [`decrypt`] ignores any extra fields, so node envelopes
|
||||
//! (which carry `did`/`pubkey`/`kid`) decrypt here too.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use argon2::Argon2;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use serde_json::json;
|
||||
|
||||
/// Envelope version. Bump only when the blob layout itself changes — and
|
||||
/// then only with a reader for the old layout (same policy as the node).
|
||||
const BACKUP_VERSION: u32 = 1;
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
/// Encrypt a JSON payload into an ADR-005 envelope.
|
||||
///
|
||||
/// The passphrase never leaves this call; the envelope carries only the
|
||||
/// salt (Argon2id parameter), the AEAD nonce, and the ciphertext.
|
||||
pub fn encrypt(payload: &str, passphrase: &str) -> Result<String> {
|
||||
if payload.is_empty() {
|
||||
bail!("backup payload is empty");
|
||||
}
|
||||
if passphrase.is_empty() {
|
||||
bail!("backup passphrase must not be empty");
|
||||
}
|
||||
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
// Same CSPRNG discipline as identity generation (getrandom, see mesh.rs):
|
||||
// OS RNG, never thread-local or derived-from-content randomness for key
|
||||
// material or nonces.
|
||||
getrandom::getrandom(&mut salt).context("OS RNG")?;
|
||||
getrandom::getrandom(&mut nonce).context("OS RNG")?;
|
||||
|
||||
let key = derive_key(passphrase, &salt)?;
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce), payload.as_bytes())
|
||||
.map_err(|_| anyhow::anyhow!("encryption failed"))?;
|
||||
|
||||
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||
blob.extend_from_slice(&salt);
|
||||
blob.extend_from_slice(&nonce);
|
||||
blob.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(json!({
|
||||
"version": BACKUP_VERSION,
|
||||
"kind": "companion",
|
||||
"encrypted": true,
|
||||
"blob": BASE64.encode(&blob),
|
||||
"timestamp": chrono_like_now(),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Decrypt an ADR-005 envelope back into its JSON payload.
|
||||
///
|
||||
/// Accepts `version: 1` envelopes regardless of `kind` or extra fields —
|
||||
/// the node's identity backups use the same blob, and being able to decrypt
|
||||
/// one here is free interop (the caller decides what to do with it).
|
||||
pub fn decrypt(envelope: &str, passphrase: &str) -> Result<String> {
|
||||
let obj: serde_json::Value =
|
||||
serde_json::from_str(envelope).context("not a JSON backup envelope")?;
|
||||
|
||||
if obj.get("version").and_then(|v| v.as_u64()) != Some(BACKUP_VERSION as u64) {
|
||||
bail!("unsupported backup version (expected {BACKUP_VERSION})");
|
||||
}
|
||||
|
||||
let blob_b64 = obj
|
||||
.get("blob")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("missing 'blob' in backup envelope")?;
|
||||
let blob = BASE64
|
||||
.decode(blob_b64)
|
||||
.context("invalid base64 in backup blob")?;
|
||||
if blob.len() < SALT_LEN + NONCE_LEN {
|
||||
bail!("backup blob too short");
|
||||
}
|
||||
|
||||
let salt = &blob[..SALT_LEN];
|
||||
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
|
||||
|
||||
let key = derive_key(passphrase, salt)?;
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let plaintext = cipher
|
||||
.decrypt(Nonce::from_slice(nonce), ciphertext)
|
||||
.map_err(|_| anyhow::anyhow!("decryption failed — wrong passphrase or corrupted backup"))?;
|
||||
|
||||
String::from_utf8(plaintext).context("decrypted payload is not valid UTF-8")
|
||||
}
|
||||
|
||||
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// RFC 3339 UTC timestamp without pulling chrono into the .so — the node's
|
||||
/// envelope field is informational (display), not part of the authenticated
|
||||
/// or derived material.
|
||||
fn chrono_like_now() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86_400;
|
||||
let rem = secs % 86_400;
|
||||
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
||||
// Civil-from-days (Howard Hinnant's algorithm), valid for 1970-2100+.
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PAYLOAD: &str = r#"{"app":"archipelago-companion","servers":["192.168.1.10|false|1301||Lab Node|fd00::1|npub1abc"]}"#;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let envelope = encrypt(PAYLOAD, "correct horse battery staple").unwrap();
|
||||
let decrypted = decrypt(&envelope, "correct horse battery staple").unwrap();
|
||||
assert_eq!(decrypted, PAYLOAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
let envelope = encrypt(PAYLOAD, "right").unwrap();
|
||||
let err = decrypt(&envelope, "wrong").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("wrong passphrase"),
|
||||
"error should name the likely cause: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_shape_matches_node_format() {
|
||||
let envelope = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
|
||||
|
||||
assert_eq!(obj["version"], 1);
|
||||
assert_eq!(obj["encrypted"], true);
|
||||
assert!(obj["kind"].as_str().is_some());
|
||||
assert!(obj["timestamp"].as_str().is_some());
|
||||
|
||||
// Blob layout is exactly the node's: base64(salt||nonce||ct) with the
|
||||
// AEAD tag inside the ciphertext — at least 16+12+16+1 bytes.
|
||||
let blob = BASE64
|
||||
.decode(obj["blob"].as_str().unwrap())
|
||||
.expect("blob is base64");
|
||||
assert!(blob.len() >= SALT_LEN + NONCE_LEN + 16 + PAYLOAD.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_salt_and_nonce_every_time() {
|
||||
let a = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let b = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let (oa, ob): (serde_json::Value, serde_json::Value) = (
|
||||
serde_json::from_str(&a).unwrap(),
|
||||
serde_json::from_str(&b).unwrap(),
|
||||
);
|
||||
assert_ne!(oa["blob"], ob["blob"], "salt/nonce must never repeat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_blob_fails_to_decrypt() {
|
||||
let envelope = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
|
||||
let blob = BASE64.decode(obj["blob"].as_str().unwrap()).unwrap();
|
||||
let mut tampered = blob.clone();
|
||||
// Flip a bit inside the ciphertext (past salt+nonce).
|
||||
tampered[SALT_LEN + NONCE_LEN] ^= 0x01;
|
||||
obj["blob"] = serde_json::Value::String(BASE64.encode(&tampered));
|
||||
assert!(decrypt(&obj.to_string(), "pw").is_err());
|
||||
}
|
||||
|
||||
/// Node identity backups use the same blob layout but carry their own
|
||||
/// envelope fields (did/pubkey/kid). Decrypt must ignore those extras —
|
||||
/// one envelope reader, two producers.
|
||||
#[test]
|
||||
fn node_style_envelope_with_extra_fields_decrypts() {
|
||||
let envelope = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
|
||||
obj["kind"] = serde_json::Value::String("node-identity".into());
|
||||
obj["did"] = serde_json::Value::String("did:key:z6Mktest".into());
|
||||
obj["pubkey"] = serde_json::Value::String("aabbcc".into());
|
||||
obj["kid"] = serde_json::Value::String("did:key:z6Mktest#key-1".into());
|
||||
let decrypted = decrypt(&obj.to_string(), "pw").unwrap();
|
||||
assert_eq!(decrypted, PAYLOAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_version_and_garbage() {
|
||||
let err = decrypt("{\"version\":99,\"blob\":\"AAAA\"}", "pw").unwrap_err();
|
||||
assert!(err.to_string().contains("version"));
|
||||
assert!(decrypt("not json", "pw").is_err());
|
||||
assert!(decrypt("{\"version\":1}", "pw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_passphrase_and_payload() {
|
||||
assert!(encrypt(PAYLOAD, "").is_err());
|
||||
assert!(encrypt("", "pw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_is_rfc3339_utc() {
|
||||
let envelope = encrypt(PAYLOAD, "pw").unwrap();
|
||||
let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap();
|
||||
let ts = obj["timestamp"].as_str().unwrap();
|
||||
// 2026-08-31T12:34:56Z — 20 chars, RFC 3339 UTC.
|
||||
assert_eq!(ts.len(), 20);
|
||||
assert!(ts.ends_with('Z'));
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[10..11], "T");
|
||||
assert!(ts.starts_with("20"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` and
|
||||
//! `com.archipelago.app.NativeCore` — JSON over strings, no codegen (the
|
||||
//! myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
@@ -127,3 +128,177 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
||||
) -> jstring {
|
||||
out(&env, mesh::status_json())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// com.archipelago.app.NativeCore — companion backup (#128) and NIP-46 remote
|
||||
// signer crypto (#139). Same library, JSON-over-strings contract.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Kotlin: `external fun backupEncrypt(payload: String, passphrase: String): String`
|
||||
/// Returns the ADR-005 envelope JSON or `{"error": …}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupEncrypt(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
payload: JString,
|
||||
passphrase: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let payload = jstr(&mut env, &payload);
|
||||
let passphrase = jstr(&mut env, &passphrase);
|
||||
let json = match crate::backup::encrypt(&payload, &passphrase) {
|
||||
Ok(envelope) => envelope,
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun backupDecrypt(envelope: String, passphrase: String): String`
|
||||
/// Returns the decrypted payload JSON or `{"error": …}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_backupDecrypt(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
envelope: JString,
|
||||
passphrase: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let envelope = jstr(&mut env, &envelope);
|
||||
let passphrase = jstr(&mut env, &passphrase);
|
||||
let json = match crate::backup::decrypt(&envelope, &passphrase) {
|
||||
Ok(payload) => payload,
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun nostrGenerateSecret(): String`
|
||||
/// Returns `{"secret": hex, "pubkey": hex, "npub": …, "nsec": …}` or `{"error": …}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrGenerateSecret(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let json = match crate::nostr::generate_secret() {
|
||||
Ok(secret) => nostr_key_info_json(&secret),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun nostrSecretFromAny(secret: String): String`
|
||||
/// Accepts hex or `nsec…`; returns key-info JSON or `{"error": …}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSecretFromAny(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let json = match crate::nostr::secret_from_any(&secret) {
|
||||
Ok(hex) => nostr_key_info_json(&hex),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
fn nostr_key_info_json(secret_hex: &str) -> String {
|
||||
match (
|
||||
crate::nostr::pubkey_hex(secret_hex),
|
||||
crate::nostr::npub_from_pubkey(&crate::nostr::pubkey_hex(secret_hex).unwrap_or_default()),
|
||||
crate::nostr::nsec_from_secret(secret_hex),
|
||||
) {
|
||||
(Ok(pubkey), Ok(npub), Ok(nsec)) => serde_json::json!({
|
||||
"secret": secret_hex,
|
||||
"pubkey": pubkey,
|
||||
"npub": npub,
|
||||
"nsec": nsec,
|
||||
})
|
||||
.to_string(),
|
||||
(e, _, _) => err_json(e.unwrap_err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun nostrParseConnectUri(uri: String): String`
|
||||
/// Returns the parsed URI fields or `{"error": …}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrParseConnectUri(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
uri: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let uri = jstr(&mut env, &uri);
|
||||
let json = match crate::nostr::parse_connect_uri(&uri) {
|
||||
Ok(info) => info.to_json().to_string(),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun nostrSignEvent(secretHex: String, eventJson: String): String`
|
||||
/// Returns the signed event JSON or `{"error": …}`. The approve/deny decision
|
||||
/// is made in Kotlin BEFORE this is called — native code never signs unasked.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSignEvent(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret_hex: JString,
|
||||
event_json: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret_hex);
|
||||
let event = jstr(&mut env, &event_json);
|
||||
let json = match crate::nostr::sign_event(&secret, &event) {
|
||||
Ok(signed) => signed,
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
macro_rules! nostr_cipher {
|
||||
($name:ident, $doc:literal, $fn:path) => {
|
||||
#[doc = $doc]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn $name(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret_hex: JString,
|
||||
peer_pub: JString,
|
||||
text: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret_hex);
|
||||
let peer = jstr(&mut env, &peer_pub);
|
||||
let text = jstr(&mut env, &text);
|
||||
let json = match $fn(&secret, &peer, &text) {
|
||||
Ok(out) => serde_json::json!({ "result": out }).to_string(),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
nostr_cipher!(
|
||||
Java_com_archipelago_app_NativeCore_nostrNip44Encrypt,
|
||||
"Kotlin: `external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String` — returns `{\"result\": payload}` or `{\"error\": …}`.",
|
||||
crate::nostr::nip44_encrypt
|
||||
);
|
||||
nostr_cipher!(
|
||||
Java_com_archipelago_app_NativeCore_nostrNip44Decrypt,
|
||||
"Kotlin: `external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String`",
|
||||
crate::nostr::nip44_decrypt
|
||||
);
|
||||
nostr_cipher!(
|
||||
Java_com_archipelago_app_NativeCore_nostrNip04Encrypt,
|
||||
"Kotlin: `external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String`",
|
||||
crate::nostr::nip04_encrypt
|
||||
);
|
||||
nostr_cipher!(
|
||||
Java_com_archipelago_app_NativeCore_nostrNip04Decrypt,
|
||||
"Kotlin: `external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String`",
|
||||
crate::nostr::nip04_decrypt
|
||||
);
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||
|
||||
pub mod backup;
|
||||
pub mod mesh;
|
||||
pub mod nostr;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod jni_glue;
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
//! NIP-46 phone-side remote signer ("bunker") crypto core.
|
||||
//!
|
||||
//! Everything that must be constant-time correct for the companion to act as
|
||||
//! a nostr remote signer: key handling (nsec/npub bech32), BIP340 schnorr
|
||||
//! event signing, NIP-44 v2 payload encryption (the mandated NIP-46
|
||||
//! transport), NIP-04 fallback decryption (deprecated, but real clients
|
||||
//! still speak it), and `nostrconnect://` URI parsing. The protocol session
|
||||
//! — relay WebSocket, JSON-RPC dispatch, approve/deny UX — lives in Kotlin;
|
||||
//! this module is the crypto and nothing but.
|
||||
//!
|
||||
//! Verified against the official NIP-44 vectors and BIP-340 reference
|
||||
//! vectors (see tests below).
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE as BASE64_URL};
|
||||
use base64::Engine;
|
||||
use bech32::{Bech32, Hrp};
|
||||
use chacha20::cipher::{KeyIvInit, StreamCipher};
|
||||
use chacha20::ChaCha20;
|
||||
use hmac::{Hmac, Mac};
|
||||
use hkdf::Hkdf;
|
||||
use secp256k1::ecdh;
|
||||
use secp256k1::schnorr::Signature;
|
||||
use secp256k1::{
|
||||
Keypair, Message, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const NIP44_VERSION: u8 = 2;
|
||||
const NIP44_SALT: &[u8] = b"nip44-v2";
|
||||
const NIP44_MIN_PAYLOAD_LEN: usize = 99; // 1 ver + 32 nonce + 32 ct + 32 mac
|
||||
const NIP44_MIN_B64_LEN: usize = 132;
|
||||
|
||||
// ── keys ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a fresh nostr secret key (hex) from the OS CSPRNG.
|
||||
pub fn generate_secret() -> Result<String> {
|
||||
loop {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::getrandom(&mut bytes).context("OS RNG")?;
|
||||
// Reject zero and >= curve order — the valid scalar range (mirrors
|
||||
// the mesh identity loop; rejection is astronomically unlikely).
|
||||
if bytes.iter().all(|&b| b == 0) {
|
||||
continue;
|
||||
}
|
||||
if SecretKey::from_slice(&bytes).is_ok() {
|
||||
return Ok(hex::encode(bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a secret key from hex or bech32 `nsec…` form into hex.
|
||||
pub fn secret_from_any(s: &str) -> Result<String> {
|
||||
let s = s.trim();
|
||||
if s.starts_with("nsec") {
|
||||
return secret_from_nsec(s);
|
||||
}
|
||||
let bytes = hex::decode(s.trim()).context("secret key must be hex or nsec")?;
|
||||
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
|
||||
Ok(hex::encode(sk.secret_bytes()))
|
||||
}
|
||||
|
||||
pub fn secret_from_nsec(nsec: &str) -> Result<String> {
|
||||
let (hrp, data) = bech32::decode(nsec).context("bad nsec encoding")?;
|
||||
if hrp.as_str() != "nsec" {
|
||||
bail!("not an nsec");
|
||||
}
|
||||
let sk = SecretKey::from_slice(&data).context("invalid nostr secret key")?;
|
||||
Ok(hex::encode(sk.secret_bytes()))
|
||||
}
|
||||
|
||||
pub fn nsec_from_secret(secret_hex: &str) -> Result<String> {
|
||||
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
|
||||
let hrp = Hrp::parse("nsec").context("nsec hrp")?;
|
||||
bech32::encode::<Bech32>(hrp, &bytes).context("nsec encoding")
|
||||
}
|
||||
|
||||
/// x-only public key (hex) for a secret key.
|
||||
/// NOTE: `Keypair::public_key()` in secp256k1 0.29 is the full compressed
|
||||
/// (33-byte) key — nostr uses x-only pubkeys, so serialize `.x_only_public_key().0`.
|
||||
pub fn pubkey_hex(secret_hex: &str) -> Result<String> {
|
||||
let kp = keypair(secret_hex)?;
|
||||
Ok(hex::encode(kp.public_key().x_only_public_key().0.serialize()))
|
||||
}
|
||||
|
||||
pub fn npub_from_pubkey(pub_hex: &str) -> Result<String> {
|
||||
let bytes = hex::decode(pub_hex.trim()).context("bad pubkey hex")?;
|
||||
let hrp = Hrp::parse("npub").context("npub hrp")?;
|
||||
bech32::encode::<Bech32>(hrp, &bytes).context("npub encoding")
|
||||
}
|
||||
|
||||
/// Parse an x-only pubkey from hex or bech32 `npub…` form into hex.
|
||||
pub fn pubkey_from_any(s: &str) -> Result<String> {
|
||||
let s = s.trim();
|
||||
let bytes = if s.starts_with("npub") {
|
||||
let (hrp, data) = bech32::decode(s).context("bad npub encoding")?;
|
||||
if hrp.as_str() != "npub" {
|
||||
bail!("not an npub");
|
||||
}
|
||||
data
|
||||
} else {
|
||||
hex::decode(s).context("pubkey must be hex or npub")?
|
||||
};
|
||||
XOnlyPublicKey::from_slice(&bytes).context("invalid x-only pubkey")?;
|
||||
Ok(hex::encode(bytes))
|
||||
}
|
||||
|
||||
fn keypair(secret_hex: &str) -> Result<Keypair> {
|
||||
let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
|
||||
let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?;
|
||||
Ok(Keypair::from_secret_key(&Secp256k1::new(), &sk))
|
||||
}
|
||||
|
||||
// ── nostrconnect:// URI ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectUri {
|
||||
/// The client's pubkey, hex.
|
||||
pub client_pubkey: String,
|
||||
/// Relays the client is listening on (≥1 by spec; kept in URI order).
|
||||
pub relays: Vec<String>,
|
||||
/// One-time pairing secret the client expects to see echoed back.
|
||||
pub secret: String,
|
||||
/// Comma-separated permission grants the client requests (display hint
|
||||
/// only — approval always stays with the human).
|
||||
pub perms: Vec<String>,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
impl ConnectUri {
|
||||
/// JSON shape for the JNI boundary (flat strings/arrays — easy to parse
|
||||
/// with org.json on the Kotlin side).
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"clientPubkey": self.client_pubkey,
|
||||
"relays": self.relays,
|
||||
"secret": self.secret,
|
||||
"perms": self.perms,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"image": self.image,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `nostrconnect://<client-pubkey>?relay=…&secret=…&perms=…&name=…`.
|
||||
///
|
||||
/// Query values are percent-decoded; `relay` may repeat. The pubkey in the
|
||||
/// host position may be hex or (non-spec but harmless) `npub…`.
|
||||
pub fn parse_connect_uri(uri: &str) -> Result<ConnectUri> {
|
||||
let uri = uri.trim();
|
||||
let rest = uri
|
||||
.strip_prefix("nostrconnect://")
|
||||
.ok_or_else(|| anyhow::anyhow!("not a nostrconnect:// URI"))?;
|
||||
|
||||
let (host, query) = match rest.split_once('?') {
|
||||
Some((h, q)) => (h, q),
|
||||
None => bail!("nostrconnect URI has no query parameters"),
|
||||
};
|
||||
let client_pubkey = pubkey_from_any(host).context("nostrconnect URI: bad client pubkey")?;
|
||||
|
||||
let mut relays = Vec::new();
|
||||
let mut secret = String::new();
|
||||
let mut perms: Vec<String> = Vec::new();
|
||||
let mut name = String::new();
|
||||
let mut url = String::new();
|
||||
let mut image = String::new();
|
||||
|
||||
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
let v = v.into_owned();
|
||||
match k.as_ref() {
|
||||
"relay" => {
|
||||
if v.starts_with("ws://") || v.starts_with("wss://") {
|
||||
relays.push(v);
|
||||
}
|
||||
}
|
||||
"secret" => secret = v,
|
||||
"perms" => perms = v.split(',').filter(|s| !s.is_empty()).map(String::from).collect(),
|
||||
"name" => name = v,
|
||||
"url" => url = v,
|
||||
"image" => image = v,
|
||||
_ => {} // forward-compat: ignore unknown params
|
||||
}
|
||||
}
|
||||
|
||||
if relays.is_empty() {
|
||||
bail!("nostrconnect URI carries no relay");
|
||||
}
|
||||
if secret.is_empty() {
|
||||
bail!("nostrconnect URI carries no secret");
|
||||
}
|
||||
|
||||
Ok(ConnectUri {
|
||||
client_pubkey,
|
||||
relays,
|
||||
secret,
|
||||
perms,
|
||||
name,
|
||||
url,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
// ── events (NIP-01 id + BIP340 signature) ─────────────────────────────────
|
||||
|
||||
/// Compute the NIP-01 event id: sha256 over the compact serialization
|
||||
/// `[0, pubkey, created_at, kind, tags, content]`.
|
||||
fn event_id(pubkey: &str, created_at: u64, kind: u64, tags: &serde_json::Value, content: &str) -> [u8; 32] {
|
||||
let serialized = serde_json::json!([
|
||||
0,
|
||||
pubkey,
|
||||
created_at,
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
]);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(serialized.to_string().as_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Sign an unsigned event `{kind, content, tags, created_at}` (pubkey filled
|
||||
/// from the secret key; `pubkey` in the input ignored) and return the signed
|
||||
/// event JSON. This is the `sign_event` NIP-46 method's core — the approve
|
||||
/// happens before this call, never inside it.
|
||||
pub fn sign_event(secret_hex: &str, event_json: &str) -> Result<String> {
|
||||
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
|
||||
let kind = ev
|
||||
.get("kind")
|
||||
.and_then(|v| v.as_u64())
|
||||
.context("event has no kind")?;
|
||||
let created_at = ev
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_u64())
|
||||
.context("event has no created_at")?;
|
||||
let tags = ev
|
||||
.get("tags")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
let content = ev
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let kp = keypair(secret_hex)?;
|
||||
let pubkey = hex::encode(kp.public_key().x_only_public_key().0.serialize());
|
||||
let id = event_id(&pubkey, created_at, kind, &tags, &content);
|
||||
|
||||
let mut aux = [0u8; 32];
|
||||
getrandom::getrandom(&mut aux).context("OS RNG")?;
|
||||
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
|
||||
&Message::from_digest(id),
|
||||
&kp,
|
||||
&aux,
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": hex::encode(id),
|
||||
"pubkey": pubkey,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": hex::encode(sig.serialize()),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Verify a signed event's id and schnorr signature (tests + defensive use).
|
||||
pub fn verify_event(event_json: &str) -> Result<()> {
|
||||
let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?;
|
||||
let pubkey = ev.get("pubkey").and_then(|v| v.as_str()).context("no pubkey")?;
|
||||
let id_hex = ev.get("id").and_then(|v| v.as_str()).context("no id")?;
|
||||
let sig_hex = ev.get("sig").and_then(|v| v.as_str()).context("no sig")?;
|
||||
let kind = ev.get("kind").and_then(|v| v.as_u64()).context("no kind")?;
|
||||
let created_at = ev.get("created_at").and_then(|v| v.as_u64()).context("no created_at")?;
|
||||
let tags = ev.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||
let content = ev.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let expected = event_id(pubkey, created_at, kind, &tags, content);
|
||||
if hex::encode(expected) != id_hex {
|
||||
bail!("event id mismatch");
|
||||
}
|
||||
|
||||
let pk = XOnlyPublicKey::from_slice(&hex::decode(pubkey)?)
|
||||
.context("bad pubkey")?;
|
||||
let sig = Signature::from_slice(&hex::decode(sig_hex)?)
|
||||
.context("bad signature")?;
|
||||
Secp256k1::new()
|
||||
.verify_schnorr(&sig, &Message::from_digest(expected), &pk)
|
||||
.context("signature verification failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-44 v2 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// ECDH shared x-coordinate (unhashed, 32 bytes) between our secret key and
|
||||
/// the peer's x-only public key. Lifting the x-only key with even-y parity
|
||||
/// is safe here: negating a point flips only y, so the shared x — the only
|
||||
/// thing NIP-44/NIP-04 consume — is unchanged.
|
||||
fn shared_x(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
|
||||
let sk_bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?;
|
||||
let sk = SecretKey::from_slice(&sk_bytes).context("invalid secret key")?;
|
||||
let peer_hex = pubkey_from_any(peer_pubkey_hex)?;
|
||||
let peer = XOnlyPublicKey::from_slice(&hex::decode(&peer_hex)?)
|
||||
.context("invalid peer pubkey")?;
|
||||
// Lift x-only key to a full public key (even-y representative).
|
||||
let full = PublicKey::from_x_only_public_key(peer, secp256k1::Parity::Even);
|
||||
let point = ecdh::shared_secret_point(&full, &sk); // 64 bytes: x || y
|
||||
let mut x = [0u8; 32];
|
||||
x.copy_from_slice(&point[..32]);
|
||||
Ok(x)
|
||||
}
|
||||
|
||||
/// NIP-44 v2 conversation key: HKDF-extract(IKM = ECDH x, salt = 'nip44-v2').
|
||||
fn conversation_key(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> {
|
||||
let x = shared_x(secret_hex, peer_pubkey_hex)?;
|
||||
let mut hk = HkdfExtractSha256::new(Some(NIP44_SALT));
|
||||
hk.input_ikm(&x);
|
||||
let (prk, _) = hk.finalize();
|
||||
let mut ck = [0u8; 32];
|
||||
ck.copy_from_slice(prk.as_slice());
|
||||
Ok(ck)
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 extract step, exposing the raw PRK (Hkdf::expand hashes with
|
||||
/// an info suffix even when info is empty, which is NOT the extract output;
|
||||
/// finalize returns (PRK, ready-to-expand Hkdf)).
|
||||
type HkdfExtractSha256 = hkdf::HkdfExtract<Sha256>;
|
||||
|
||||
/// Per-message keys: HKDF-expand(PRK = conversation key, info = nonce, L = 76)
|
||||
/// sliced into chacha_key[32] chacha_nonce[12] hmac_key[32].
|
||||
fn message_keys(ck: &[u8; 32], nonce: &[u8; 32]) -> ([u8; 32], [u8; 12], [u8; 32]) {
|
||||
let hk = Hkdf::<Sha256>::from_prk(ck).expect("conversation key is 32 bytes");
|
||||
let mut okm = [0u8; 76];
|
||||
hk.expand(nonce, &mut okm).expect("76 <= 255 * hash len");
|
||||
let mut chacha_key = [0u8; 32];
|
||||
let mut chacha_nonce = [0u8; 12];
|
||||
let mut hmac_key = [0u8; 32];
|
||||
chacha_key.copy_from_slice(&okm[..32]);
|
||||
chacha_nonce.copy_from_slice(&okm[32..44]);
|
||||
hmac_key.copy_from_slice(&okm[44..76]);
|
||||
(chacha_key, chacha_nonce, hmac_key)
|
||||
}
|
||||
|
||||
/// NIP-44 padding: 2-byte big-endian plaintext length (6 bytes, `0x0000` +
|
||||
/// u32, when ≥ 65536), zero-padded to the next power-of-two-ish chunk.
|
||||
fn calc_padded_len(unpadded: usize) -> usize {
|
||||
let unpadded: u64 = unpadded as u64;
|
||||
if unpadded <= 32 {
|
||||
return 32;
|
||||
}
|
||||
let next_power = 1u64 << ((63 - (unpadded - 1).leading_zeros()) + 1);
|
||||
let chunk = if next_power <= 256 { 32 } else { next_power / 8 };
|
||||
(chunk * ((unpadded - 1) / chunk + 1)) as usize
|
||||
}
|
||||
|
||||
fn pad(plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
if plaintext.is_empty() || plaintext.len() > u32::MAX as usize {
|
||||
bail!("invalid plaintext length");
|
||||
}
|
||||
let prefix: Vec<u8> = if plaintext.len() >= 65536 {
|
||||
let mut p = vec![0u8, 0u8];
|
||||
p.extend_from_slice(&(plaintext.len() as u32).to_be_bytes());
|
||||
p
|
||||
} else {
|
||||
(plaintext.len() as u16).to_be_bytes().to_vec()
|
||||
};
|
||||
let padded_len = calc_padded_len(plaintext.len());
|
||||
let mut out = Vec::with_capacity(prefix.len() + padded_len);
|
||||
out.extend_from_slice(&prefix);
|
||||
out.extend_from_slice(plaintext);
|
||||
out.resize(prefix.len() + padded_len, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn unpad(padded: &[u8]) -> Result<Vec<u8>> {
|
||||
if padded.len() < 2 {
|
||||
bail!("invalid padding");
|
||||
}
|
||||
let first_two = u16::from_be_bytes([padded[0], padded[1]]);
|
||||
let (unpadded_len, prefix_len) = if first_two == 0 {
|
||||
if padded.len() < 6 {
|
||||
bail!("invalid padding");
|
||||
}
|
||||
(u32::from_be_bytes([padded[2], padded[3], padded[4], padded[5]]) as usize, 6)
|
||||
} else {
|
||||
(first_two as usize, 2)
|
||||
};
|
||||
if unpadded_len == 0
|
||||
|| padded.len() < prefix_len + unpadded_len
|
||||
|| padded.len() != prefix_len + calc_padded_len(unpadded_len)
|
||||
{
|
||||
bail!("invalid padding");
|
||||
}
|
||||
Ok(padded[prefix_len..prefix_len + unpadded_len].to_vec())
|
||||
}
|
||||
|
||||
/// Constant-time equality (length differs → false; content comparison never
|
||||
/// short-circuits on a byte).
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// NIP-44 v2 encrypt: returns `base64(0x02 || nonce || ciphertext || mac)`.
|
||||
pub fn nip44_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
|
||||
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
|
||||
let mut nonce = [0u8; 32];
|
||||
getrandom::getrandom(&mut nonce).context("OS RNG")?;
|
||||
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
|
||||
|
||||
let mut padded = pad(plaintext.as_bytes())?;
|
||||
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
|
||||
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
|
||||
mac.update(&nonce);
|
||||
mac.update(&padded);
|
||||
let tag = mac.finalize().into_bytes();
|
||||
|
||||
let mut out = Vec::with_capacity(1 + 32 + padded.len() + 32);
|
||||
out.push(NIP44_VERSION);
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&padded);
|
||||
out.extend_from_slice(&tag);
|
||||
Ok(BASE64.encode(&out))
|
||||
}
|
||||
|
||||
/// NIP-44 v2 decrypt of a `base64(0x02 || …)` payload.
|
||||
pub fn nip44_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
|
||||
if payload.starts_with('#') {
|
||||
bail!("unknown NIP-44 version (non-base64 payload)");
|
||||
}
|
||||
let data = BASE64
|
||||
.decode(payload.trim())
|
||||
.context("payload is not base64")?;
|
||||
if payload.len() < NIP44_MIN_B64_LEN || data.len() < NIP44_MIN_PAYLOAD_LEN {
|
||||
bail!("invalid NIP-44 payload size");
|
||||
}
|
||||
if data[0] != NIP44_VERSION {
|
||||
bail!("unknown NIP-44 version {}", data[0]);
|
||||
}
|
||||
let nonce: [u8; 32] = data[1..33].try_into().expect("slice is 32");
|
||||
let ciphertext = &data[33..data.len() - 32];
|
||||
let mac_bytes = &data[data.len() - 32..];
|
||||
|
||||
let ck = conversation_key(secret_hex, peer_pubkey_hex)?;
|
||||
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
|
||||
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).expect("hmac accepts any key len");
|
||||
mac.update(&nonce);
|
||||
mac.update(ciphertext);
|
||||
let expected = mac.finalize().into_bytes();
|
||||
if !ct_eq(&expected, mac_bytes) {
|
||||
bail!("invalid NIP-44 MAC");
|
||||
}
|
||||
|
||||
let mut buf = ciphertext.to_vec();
|
||||
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut buf);
|
||||
let plaintext = unpad(&buf)?;
|
||||
String::from_utf8(plaintext).context("decrypted payload is not UTF-8")
|
||||
}
|
||||
|
||||
// ── NIP-04 (deprecated transport, still spoken by real clients) ────────────
|
||||
|
||||
/// NIP-04 encrypt: AES-256-CBC, key = raw ECDH x-coordinate (unhashed — the
|
||||
/// spec's quirk), output `<base64 ct>?iv=<base64 iv>`.
|
||||
pub fn nip04_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result<String> {
|
||||
use aes::cipher::{BlockEncryptMut, KeyIvInit};
|
||||
type Enc = cbc::Encryptor<aes::Aes256>;
|
||||
|
||||
let key = shared_x(secret_hex, peer_pubkey_hex)?;
|
||||
let mut iv = [0u8; 16];
|
||||
getrandom::getrandom(&mut iv).context("OS RNG")?;
|
||||
let ct = Enc::new(&key.into(), &iv.into()).encrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(plaintext.as_bytes());
|
||||
Ok(format!("{}?iv={}", BASE64.encode(&ct), BASE64.encode(iv)))
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt of `<base64 ct>?iv=<base64 iv>`.
|
||||
pub fn nip04_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result<String> {
|
||||
use aes::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
type Dec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
let (ct_b64, iv_b64) = payload
|
||||
.trim()
|
||||
.split_once("?iv=")
|
||||
.ok_or_else(|| anyhow::anyhow!("not a NIP-04 payload (no iv)"))?;
|
||||
let ct = BASE64.decode(ct_b64).context("bad NIP-04 ciphertext base64")?;
|
||||
let iv: [u8; 16] = BASE64
|
||||
.decode(iv_b64)
|
||||
.context("bad NIP-04 iv base64")?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("NIP-04 iv must be 16 bytes"))?;
|
||||
let key = shared_x(secret_hex, peer_pubkey_hex)?;
|
||||
let pt = Dec::new(&key.into(), &iv.into())
|
||||
.decrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(&ct)
|
||||
.map_err(|_| anyhow::anyhow!("NIP-04 decryption failed"))?;
|
||||
String::from_utf8(pt).context("decrypted payload is not UTF-8")
|
||||
}
|
||||
|
||||
/// URL-safe base64 for keys that cross the JNI boundary — unused by the
|
||||
/// protocol but handy for the Kotlin side; keep the engine in one place.
|
||||
pub fn b64_url(data: &[u8]) -> String {
|
||||
BASE64_URL.encode(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── official NIP-44 vectors (paulmillr/nip44 nip44.vectors.json) ──────
|
||||
|
||||
#[test]
|
||||
fn nip44_official_conversation_keys() {
|
||||
let vectors: &[(&str, &str, &str)] = &[
|
||||
("315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"),
|
||||
("98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"),
|
||||
("86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"),
|
||||
// sec1 == pub2 (ECDH with self)
|
||||
("0000000000000000000000000000000000000000000000000000000000000001", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e"),
|
||||
];
|
||||
for (sec1, pub2, expected) in vectors {
|
||||
let ck = conversation_key(sec1, pub2).unwrap();
|
||||
assert_eq!(hex::encode(ck), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip44_official_message_keys() {
|
||||
let ck_bytes: [u8; 32] = hex::decode("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let vectors: &[(&str, &str, &str, &str)] = &[
|
||||
("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", "c4ad129bb01180c0933a160c", "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"),
|
||||
("ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", "50bb859aa2dde938cc49ec7a", "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"),
|
||||
];
|
||||
for (nonce_h, ck_exp, cn_exp, hk_exp) in vectors {
|
||||
let nonce: [u8; 32] = hex::decode(nonce_h).unwrap().try_into().unwrap();
|
||||
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck_bytes, &nonce);
|
||||
assert_eq!(hex::encode(chacha_key), *ck_exp);
|
||||
assert_eq!(hex::encode(chacha_nonce), *cn_exp);
|
||||
assert_eq!(hex::encode(hmac_key), *hk_exp);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip44_offical_padded_len() {
|
||||
let vectors: &[(usize, usize)] = &[
|
||||
(16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64), (64, 64),
|
||||
(65, 96), (100, 128), (111, 128), (200, 224), (250, 256), (320, 320),
|
||||
(383, 384), (384, 384), (400, 448), (500, 512), (512, 512), (515, 640),
|
||||
(700, 768), (800, 896), (900, 1024), (1020, 1024), (65536, 65536),
|
||||
];
|
||||
for (unpadded, padded) in vectors {
|
||||
assert_eq!(calc_padded_len(*unpadded), *padded, "unpadded {unpadded}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip44_official_encrypt_vectors() {
|
||||
// (sec1, sec2, nonce, plaintext, payload) — decrypt with the peer's
|
||||
// view (sec2, pub(sec1)) so this also proves key symmetry.
|
||||
let vectors: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"a",
|
||||
"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"),
|
||||
("0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||
"🍕🫃",
|
||||
"AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"),
|
||||
("5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||
"4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||
"b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||
"表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||
"ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="),
|
||||
("eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||
"dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||
"2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||
"( ͡° ͜ʖ ͡°)",
|
||||
"AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"),
|
||||
("d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||
"b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||
"a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||
"🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||
"AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="),
|
||||
];
|
||||
for (sec1, sec2, nonce_hex, plaintext, payload) in vectors {
|
||||
// Encrypt from A to B with the fixed nonce must reproduce the
|
||||
// official payload byte-for-byte.
|
||||
let pub1 = pubkey_hex(sec1).unwrap();
|
||||
let made = {
|
||||
let ck = conversation_key(sec1, &pubkey_hex(sec2).unwrap()).unwrap();
|
||||
let nonce: [u8; 32] = hex::decode(nonce_hex).unwrap().try_into().unwrap();
|
||||
let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce);
|
||||
let mut padded = pad(plaintext.as_bytes()).unwrap();
|
||||
ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded);
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&hmac_key).unwrap();
|
||||
mac.update(&nonce);
|
||||
mac.update(&padded);
|
||||
let tag = mac.finalize().into_bytes();
|
||||
let mut out = vec![NIP44_VERSION];
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&padded);
|
||||
out.extend_from_slice(&tag);
|
||||
BASE64.encode(&out)
|
||||
};
|
||||
assert_eq!(&made, payload, "encrypt vector for {plaintext:?}");
|
||||
|
||||
// Decrypt from B's view of A (key-role symmetry).
|
||||
let got = nip44_decrypt(sec2, &pub1, payload).unwrap();
|
||||
assert_eq!(got, *plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip44_round_trip_and_failures() {
|
||||
let sk_a = generate_secret().unwrap();
|
||||
let sk_b = generate_secret().unwrap();
|
||||
let pub_b = pubkey_hex(&sk_b).unwrap();
|
||||
let pub_a = pubkey_hex(&sk_a).unwrap();
|
||||
|
||||
let msg = "hello, remote signer";
|
||||
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
|
||||
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), msg);
|
||||
|
||||
// Round-trip long content across the 65536 prefix boundary.
|
||||
let long = "x".repeat(70_000);
|
||||
let payload = nip44_encrypt(&sk_a, &pub_b, &long).unwrap();
|
||||
assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), long);
|
||||
|
||||
// Wrong peer key must fail the MAC, not return garbage.
|
||||
let stranger = generate_secret().unwrap();
|
||||
assert!(nip44_decrypt(&sk_b, &pub_b, &payload).is_err());
|
||||
let _ = stranger;
|
||||
|
||||
// Tampered payload fails.
|
||||
let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap();
|
||||
let mut tampered = BASE64.decode(&payload).unwrap();
|
||||
let n = tampered.len();
|
||||
tampered[n - 1] ^= 0x01;
|
||||
assert!(nip44_decrypt(&sk_b, &pub_a, &BASE64.encode(&tampered)).is_err());
|
||||
|
||||
// Truncated payload fails.
|
||||
assert!(nip44_decrypt(&sk_b, &pub_a, "AAAA").is_err());
|
||||
}
|
||||
|
||||
// ── BIP-340 official vectors (github.com/bitcoin/bips test vectors) ────
|
||||
|
||||
#[test]
|
||||
fn bip340_reference_sign_vectors() {
|
||||
// (seckey, pubkey, aux, msg, expected sig) — indices 0/1/2 of the
|
||||
// official BIP-340 `bip-0340/test-vectors.csv` "should sign" set,
|
||||
// transcribed from the file itself (x(3G) additionally verified
|
||||
// by independent scalar-math in the review notes for this commit).
|
||||
let vectors: &[(&str, &str, &str, &str, &str)] = &[
|
||||
("0000000000000000000000000000000000000000000000000000000000000003",
|
||||
"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"),
|
||||
("B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF",
|
||||
"DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659",
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
|
||||
"6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A"),
|
||||
("C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9",
|
||||
"DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8",
|
||||
"C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906",
|
||||
"7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C",
|
||||
"5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7"),
|
||||
];
|
||||
for (sk_hex, pk_hex, aux_hex, msg_hex, sig_hex) in vectors {
|
||||
let sk_bytes = hex::decode(sk_hex).unwrap();
|
||||
let sk = SecretKey::from_slice(&sk_bytes).unwrap();
|
||||
let kp = Keypair::from_secret_key(&Secp256k1::new(), &sk);
|
||||
assert_eq!(hex::encode(kp.public_key().x_only_public_key().0.serialize()).to_uppercase(), *pk_hex);
|
||||
|
||||
let msg: [u8; 32] = hex::decode(msg_hex).unwrap().try_into().unwrap();
|
||||
let aux: [u8; 32] = hex::decode(aux_hex).unwrap().try_into().unwrap();
|
||||
let sig = Secp256k1::new().sign_schnorr_with_aux_rand(
|
||||
&Message::from_digest(msg),
|
||||
&kp,
|
||||
&aux,
|
||||
);
|
||||
assert_eq!(hex::encode(sig.serialize()).to_uppercase(), *sig_hex);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_signing_round_trip() {
|
||||
let sk = generate_secret().unwrap();
|
||||
let unsigned = r#"{"kind":22242,"content":"{\"challenge\":\"abc123\"}","tags":[["relay","ws://127.0.0.1:7777"]],"created_at":1725100000}"#;
|
||||
let signed = sign_event(&sk, unsigned).unwrap();
|
||||
verify_event(&signed).unwrap();
|
||||
|
||||
let ev: serde_json::Value = serde_json::from_str(&signed).unwrap();
|
||||
assert_eq!(ev["kind"], 22242);
|
||||
assert_eq!(ev["pubkey"], pubkey_hex(&sk).unwrap());
|
||||
// Tampering with content breaks the id, which breaks verification.
|
||||
let mut tampered = ev.clone();
|
||||
tampered["content"] = serde_json::Value::String("nope".into());
|
||||
assert!(verify_event(&tampered.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_uri_parsing() {
|
||||
let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&perms=nip44_encrypt%2Csign_event%3A22242&name=My+Client&secret=0s8j2djs&relay=ws%3A%2F%2F192.168.1.20%3A7777";
|
||||
let info = parse_connect_uri(uri).unwrap();
|
||||
assert_eq!(info.client_pubkey, "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5");
|
||||
assert_eq!(
|
||||
info.relays,
|
||||
vec!["wss://relay1.example.com", "ws://192.168.1.20:7777"]
|
||||
);
|
||||
assert_eq!(info.secret, "0s8j2djs");
|
||||
assert_eq!(info.perms, vec!["nip44_encrypt", "sign_event:22242"]);
|
||||
assert_eq!(info.name, "My Client");
|
||||
|
||||
// npub client keys and unknown params tolerated — the npub is
|
||||
// generated through our own encoder so the test carries no
|
||||
// hand-transcribed bech32 string.
|
||||
let sk1 = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
let npub = npub_from_pubkey(&pubkey_hex(sk1).unwrap()).unwrap();
|
||||
let pubkey = pubkey_from_any(&npub).unwrap();
|
||||
let uri = format!("nostrconnect://{npub}?relay=wss://r&secret=s&future=1");
|
||||
let info = parse_connect_uri(&uri).unwrap();
|
||||
assert_eq!(info.client_pubkey, pubkey);
|
||||
assert_eq!(info.relays, vec!["wss://r"]);
|
||||
|
||||
assert!(parse_connect_uri("bunker://abc?relay=wss://r&secret=s").is_err());
|
||||
assert!(parse_connect_uri("nostrconnect://zz?relay=wss://r&secret=s").is_err());
|
||||
assert!(parse_connect_uri("nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?name=x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip04_round_trip_and_cross_check() {
|
||||
let sk_a = generate_secret().unwrap();
|
||||
let sk_b = generate_secret().unwrap();
|
||||
let pub_b = pubkey_hex(&sk_b).unwrap();
|
||||
let pub_a = pubkey_hex(&sk_a).unwrap();
|
||||
|
||||
let payload = nip04_encrypt(&sk_a, &pub_b, "old client hello").unwrap();
|
||||
assert!(payload.contains("?iv="));
|
||||
assert_eq!(nip04_decrypt(&sk_b, &pub_a, &payload).unwrap(), "old client hello");
|
||||
|
||||
// Wrong key must fail (PKCS#7 padding check) rather than return garbage.
|
||||
assert!(nip04_decrypt(&sk_a, &pub_a, &payload).is_err());
|
||||
assert!(nip04_decrypt(&sk_b, &pub_b, &payload).is_err());
|
||||
assert!(nip04_decrypt(&sk_b, &pub_a, "not-a-payload").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_encoding_round_trip() {
|
||||
let sk = generate_secret().unwrap();
|
||||
let nsec = nsec_from_secret(&sk).unwrap();
|
||||
assert!(nsec.starts_with("nsec1"));
|
||||
assert_eq!(secret_from_nsec(&nsec).unwrap(), sk);
|
||||
assert_eq!(secret_from_any(&nsec).unwrap(), sk);
|
||||
assert_eq!(secret_from_any(&sk).unwrap(), sk);
|
||||
|
||||
let pk = pubkey_hex(&sk).unwrap();
|
||||
let npub = npub_from_pubkey(&pk).unwrap();
|
||||
assert!(npub.starts_with("npub1"));
|
||||
assert_eq!(pubkey_from_any(&npub).unwrap(), pk);
|
||||
assert_eq!(pubkey_from_any(&pk).unwrap(), pk);
|
||||
|
||||
// The famous even-y lift edge case: pubkey of sk=1 is x(G) (y is odd);
|
||||
// shared_x with oneself is exactly x(G) — pins the unhashed-x ECDH and
|
||||
// the even-parity lift in one assertion (x is invariant under y-negation,
|
||||
// so the lift is safe for NIP-44/NIP-04 keys).
|
||||
let g_x = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
assert_eq!(
|
||||
pubkey_hex("0000000000000000000000000000000000000000000000000000000000000001").unwrap(),
|
||||
g_x
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(
|
||||
shared_x("0000000000000000000000000000000000000000000000000000000000000001", g_x).unwrap()
|
||||
),
|
||||
g_x
|
||||
);
|
||||
assert!(secret_from_nsec("npub1").is_err());
|
||||
}
|
||||
|
||||
/// The mesh ULA is a PURE function of the node's public key:
|
||||
/// `fd ‖ sha256(x-only pubkey)[0..15]` (fips identity/node_addr.rs →
|
||||
/// identity/address.rs). That is what makes "address by npub" work —
|
||||
/// Termux's fipssh helper, and any future DNS-style resolver, just
|
||||
/// computes what the fips daemon's DNS answers.
|
||||
#[test]
|
||||
fn npub_derives_the_same_mesh_ula_as_the_fips_identity() {
|
||||
for seed in [0x42u8, 0x07, 0x31] {
|
||||
// 0xff… would exceed the curve order — secret keys must be valid scalars.
|
||||
let secret = [seed; 32];
|
||||
let id = fips::Identity::from_secret_bytes(&secret).unwrap();
|
||||
let npub = id.npub();
|
||||
let expected = id.address().to_ipv6().to_string();
|
||||
|
||||
let pubkey_hex = pubkey_from_any(&npub).unwrap();
|
||||
let pk = hex::decode(&pubkey_hex).unwrap();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&pk);
|
||||
let hash = hasher.finalize();
|
||||
let mut ula = [0u8; 16];
|
||||
ula[0] = 0xfd;
|
||||
ula[1..].copy_from_slice(&hash[..15]);
|
||||
assert_eq!(std::net::Ipv6Addr::from(ula).to_string(), expected, "npub {npub}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/data/data/com.termux/files/usr/bin/sh
|
||||
# fipssh — SSH to an Archipelago FIPS mesh node BY NPUB.
|
||||
#
|
||||
# The mesh ULA is a pure function of the node's public key (verified against
|
||||
# the fips crate itself — archy-fips-core's npub_derives_the_same_mesh_ula
|
||||
# test, and the Android tools commit that shipped this script):
|
||||
#
|
||||
# ula = fd || sha256(x-only pubkey)[0..15]
|
||||
#
|
||||
# so the npub IS the address: no DNS server, no mesh query, works offline.
|
||||
# The node's fips daemon answers the same question through its DNS resolver
|
||||
# (core/archipelago/src/fips/dial.rs) — this is the phone-side equivalent.
|
||||
#
|
||||
# Setup (Termux): pkg install python openssh
|
||||
# Usage:
|
||||
# fipssh <user>@npub1… [ssh args…] connect
|
||||
# fipssh npub1… connect as $FIPSSH_USER
|
||||
# fipssh --resolve npub1… print the ULA and exit
|
||||
#
|
||||
# The companion's split tunnel carries the connection (fd00::/8 routes the
|
||||
# whole device while the mesh is up) — at home on LAN, away via the anchors.
|
||||
# The node still has to allow port 22 through its fips0 firewall: see
|
||||
# docs/HANDOFF-2026-08-31-ssh-over-mesh.md (the interim 90-ssh.nft drop-in,
|
||||
# restricted to your phone's ULA, until the node-side toggle ships).
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 1
|
||||
}
|
||||
|
||||
RESOLVE_ONLY=0
|
||||
if [ "${1:-}" = "--resolve" ]; then
|
||||
RESOLVE_ONLY=1
|
||||
shift
|
||||
fi
|
||||
[ $# -ge 1 ] || usage
|
||||
|
||||
TARGET="$1"
|
||||
shift 2>/dev/null || true
|
||||
|
||||
case "$TARGET" in
|
||||
*npub1*)
|
||||
case "$TARGET" in
|
||||
*@npub1*) USER_PART="${TARGET%%@*}"; N_PUB="${TARGET#*@}" ;;
|
||||
npub1*)
|
||||
USER_PART="${FIPSSH_USER:-}"
|
||||
N_PUB="$TARGET"
|
||||
if [ -z "$USER_PART" ] && [ "$RESOLVE_ONLY" = 0 ]; then
|
||||
echo "fipssh: no user given (use user@npub… or set FIPSSH_USER)" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*) echo "fipssh: expected [user@]npub1…, got '$TARGET'" >&2; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "fipssh: '$TARGET' is not an npub (expected [user@]npub1…)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || {
|
||||
echo "fipssh: python3 not found — run: pkg install python" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ULA=$(python3 - "$N_PUB" <<'PYEOF'
|
||||
import hashlib, ipaddress, sys
|
||||
|
||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def bech32_polymod(values):
|
||||
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
chk ^= gen[i] if ((top >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def bech32_hrp_expand(hrp):
|
||||
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
|
||||
|
||||
def bech32_verify_checksum(hrp, data):
|
||||
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
|
||||
|
||||
|
||||
def bech32_decode(s):
|
||||
if any(ord(c) < 33 or ord(c) > 126 for c in s):
|
||||
raise ValueError("bad character")
|
||||
if s.lower() != s and s.upper() != s:
|
||||
raise ValueError("mixed case")
|
||||
s = s.lower()
|
||||
pos = s.rfind("1")
|
||||
if pos < 1 or pos + 7 > len(s) or len(s) > 90:
|
||||
raise ValueError("bad separator")
|
||||
hrp = s[:pos]
|
||||
data = [CHARSET.find(c) for c in s[pos + 1:]]
|
||||
if -1 in data:
|
||||
raise ValueError("bad data character")
|
||||
if not bech32_verify_checksum(hrp, data):
|
||||
raise ValueError("bad checksum — typo in the npub?")
|
||||
return hrp, data[:-6]
|
||||
|
||||
|
||||
def convertbits(data, frombits, tobits):
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret = bytearray()
|
||||
maxv = (1 << tobits) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
raise ValueError("bad value")
|
||||
acc = (acc << frombits) | value
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
raise ValueError("bad padding")
|
||||
return bytes(ret)
|
||||
|
||||
|
||||
npub = sys.argv[1]
|
||||
hrp, data = bech32_decode(npub)
|
||||
if hrp != "npub":
|
||||
raise ValueError(f"expected hrp 'npub', got '{hrp}'")
|
||||
pubkey = convertbits(data, 5, 8)
|
||||
if len(pubkey) != 32:
|
||||
raise ValueError(f"npub data must be 32 bytes, got {len(pubkey)}")
|
||||
# ula = fd || sha256(pubkey)[0..15] — mirrors fips identity/node_addr.rs +
|
||||
# identity/address.rs (FIPS_ADDRESS_PREFIX = 0xfd).
|
||||
ula = bytes([0xFD]) + hashlib.sha256(pubkey).digest()[:15]
|
||||
print(ipaddress.IPv6Address(ula).compressed)
|
||||
PYEOF
|
||||
) || exit 1
|
||||
|
||||
if [ "$RESOLVE_ONLY" = 1 ]; then
|
||||
echo "$ULA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec ssh "${USER_PART}@${ULA}" "$@"
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NIP-46 test client for the Archipelago companion's Remote Signer (#139).
|
||||
|
||||
Plays the role the node's login flow will play (rust-nostr nostr-connect
|
||||
client): generates a nostrconnect:// pairing QR, connects to a relay, waits
|
||||
for the phone's bunker `connect` (secret echo), acks it, then exercises
|
||||
get_public_key + sign_event and VERIFIES the returned schnorr signature with
|
||||
independent pure-Python BIP-340 code (no shared code with the phone's Rust).
|
||||
|
||||
Run it on your computer next to the phone:
|
||||
|
||||
python3 -m venv /tmp/nip46env
|
||||
/tmp/nip46env/bin/pip install websockets qrcode
|
||||
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py [--relay wss://relay.damus.io]
|
||||
|
||||
…then on the phone: hub menu (three-finger hold) → Remote Signer →
|
||||
Generate key (once) → Scan pairing QR → point at the terminal QR → Approve.
|
||||
|
||||
Pure Python (no deps for the crypto; websockets + qrcode for transport/QR).
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import websockets # pip install websockets
|
||||
|
||||
# ── secp256k1 / BIP-340 (independent of the phone's Rust code) ──────────────
|
||||
|
||||
P = 2**256 - 2**32 - 977
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
G = (GX, GY)
|
||||
|
||||
|
||||
def _add(pt1, pt2):
|
||||
if pt1 is None:
|
||||
return pt2
|
||||
if pt2 is None:
|
||||
return pt1
|
||||
x1, y1 = pt1
|
||||
x2, y2 = pt2
|
||||
if x1 == x2 and (y1 + y2) % P == 0:
|
||||
return None
|
||||
if pt1 == pt2:
|
||||
lam = (3 * x1 * x1) * pow(2 * y1, -1, P) % P
|
||||
else:
|
||||
lam = (y2 - y1) * pow(x2 - x1, -1, P) % P
|
||||
x3 = (lam * lam - x1 - x2) % P
|
||||
return (x3, (lam * (x1 - x3) - y1) % P)
|
||||
|
||||
|
||||
def _mul(k, pt):
|
||||
r = None
|
||||
while k:
|
||||
if k & 1:
|
||||
r = _add(r, pt)
|
||||
pt = _add(pt, pt)
|
||||
k >>= 1
|
||||
return r
|
||||
|
||||
|
||||
def lift_x(x):
|
||||
if x >= P:
|
||||
return None
|
||||
y_sq = (pow(x, 3, P) + 7) % P
|
||||
y = pow(y_sq, (P + 1) // 4, P)
|
||||
if y * y % P != y_sq:
|
||||
return None
|
||||
return (x, y if y % 2 == 0 else P - y)
|
||||
|
||||
|
||||
def tagged(tag: bytes, data: bytes) -> bytes:
|
||||
"""BIP-340 tagged hash: sha256(hash(tag) || hash(tag) || data)."""
|
||||
th = hashlib.sha256(tag).digest()
|
||||
return hashlib.sha256(th + th + data).digest()
|
||||
|
||||
|
||||
def bip340_sign(msg: bytes, seckey: int, aux: bytes) -> bytes:
|
||||
d = seckey if seckey <= N - 1 else seckey - N
|
||||
pub = _mul(d, G)
|
||||
if pub[1] % 2 != 0:
|
||||
d = N - d
|
||||
t = bytes(a ^ b for a, b in zip(d.to_bytes(32, "big"), tagged(b"BIP0340/aux", aux)))
|
||||
rand = tagged(b"BIP0340/nonce", t + pub[0].to_bytes(32, "big") + msg)
|
||||
k = int.from_bytes(rand, "big") % N
|
||||
assert k > 0
|
||||
R = _mul(k, G)
|
||||
if R[1] % 2 != 0:
|
||||
k = N - k
|
||||
e = int.from_bytes(tagged(b"BIP0340/challenge", R[0].to_bytes(32, "big") + pub[0].to_bytes(32, "big") + msg), "big") % N
|
||||
return R[0].to_bytes(32, "big") + ((k + e * d) % N).to_bytes(32, "big")
|
||||
|
||||
|
||||
def bip340_verify(msg: bytes, pubkey_x: bytes, sig: bytes) -> bool:
|
||||
"""Check s·G − e·P == R with even-y R and x(R) == r (BIP-340)."""
|
||||
if len(sig) != 64 or len(pubkey_x) != 32:
|
||||
return False
|
||||
pub = lift_x(int.from_bytes(pubkey_x, "big"))
|
||||
if pub is None:
|
||||
return False
|
||||
r = int.from_bytes(sig[:32], "big")
|
||||
s = int.from_bytes(sig[32:], "big")
|
||||
if r >= P or s >= N:
|
||||
return False
|
||||
e = int.from_bytes(tagged(b"BIP0340/challenge", sig[:32] + pubkey_x + msg), "big") % N
|
||||
sg = _mul(s, G)
|
||||
ep = _mul(e, pub)
|
||||
neg_ep = (ep[0], (P - ep[1]) % P)
|
||||
rp = _add(sg, neg_ep)
|
||||
return rp is not None and rp[0] == r and rp[1] % 2 == 0
|
||||
|
||||
|
||||
def ecdh_x(secret_hex: str, peer_x_hex: str) -> bytes:
|
||||
"""Raw ECDH x-coordinate against an x-only peer key (even-y lift)."""
|
||||
peer = lift_x(int(peer_x_hex, 16))
|
||||
assert peer is not None, "peer pubkey not on curve"
|
||||
pt = _mul(int(secret_hex, 16) % N, peer)
|
||||
return pt[0].to_bytes(32, "big")
|
||||
|
||||
|
||||
# ── NIP-44 v2 (pure python, spec-literal) ────────────────────────────────────
|
||||
|
||||
def hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
||||
t = b""
|
||||
out = b""
|
||||
i = 1
|
||||
while len(out) < length:
|
||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
||||
out += t
|
||||
i += 1
|
||||
return out[:length]
|
||||
|
||||
|
||||
def _rotl(x: int, n: int) -> int:
|
||||
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _qr(s, a, b, c, d):
|
||||
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 16)
|
||||
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 12)
|
||||
s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] ^= s[a]; s[d] = _rotl(s[d], 8)
|
||||
s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] ^= s[c]; s[b] = _rotl(s[b], 7)
|
||||
|
||||
|
||||
def chacha20_block(key: bytes, counter: int, nonce: bytes) -> bytes:
|
||||
consts = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574]
|
||||
state = consts + list(struct.unpack("<8I", key)) + [counter] + list(struct.unpack("<3I", nonce))
|
||||
working = list(state)
|
||||
for _ in range(10):
|
||||
_qr(working, 0, 4, 8, 12); _qr(working, 1, 5, 9, 13)
|
||||
_qr(working, 2, 6, 10, 14); _qr(working, 3, 7, 11, 15)
|
||||
_qr(working, 0, 5, 10, 15); _qr(working, 1, 6, 11, 12)
|
||||
_qr(working, 2, 7, 8, 13); _qr(working, 3, 4, 9, 14)
|
||||
return struct.pack("<16I", *[(x + y) & 0xFFFFFFFF for x, y in zip(working, state)])
|
||||
|
||||
|
||||
def chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
|
||||
counter = 0 # NIP-44: "ChaCha20 (RFC 8439) with starting counter set to 0"
|
||||
out = bytearray()
|
||||
for i in range(0, len(data), 64):
|
||||
ks = chacha20_block(key, counter, nonce)
|
||||
chunk = data[i:i + 64]
|
||||
out += bytes(a ^ b for a, b in zip(chunk, ks))
|
||||
counter += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def calc_padded_len(n: int) -> int:
|
||||
if n <= 32:
|
||||
return 32
|
||||
power = 1 << ((n - 1).bit_length())
|
||||
chunk = 32 if power <= 256 else power // 8
|
||||
return chunk * ((n - 1) // chunk + 1)
|
||||
|
||||
|
||||
def nip44_encrypt(secret_hex: str, peer_hex: str, plaintext: str) -> str:
|
||||
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
|
||||
nonce = secrets.token_bytes(32)
|
||||
okm = hkdf_expand(ck, nonce, 76)
|
||||
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
|
||||
pt = plaintext.encode()
|
||||
padded = (len(pt).to_bytes(2, "big") if len(pt) < 65536 else b"\x00\x00" + len(pt).to_bytes(4, "big")) + pt
|
||||
padded += b"\x00" * (calc_padded_len(len(pt)) - len(pt))
|
||||
ct = chacha20(key, iv, padded)
|
||||
mac = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
|
||||
return base64.b64encode(bytes([2]) + nonce + ct + mac).decode()
|
||||
|
||||
|
||||
def nip44_decrypt(secret_hex: str, peer_hex: str, payload: str) -> str:
|
||||
data = base64.b64decode(payload)
|
||||
assert data[0] == 2, "only NIP-44 v2 supported"
|
||||
nonce, ct, mac = data[1:33], data[33:-32], data[-32:]
|
||||
ck = hkdf_extract(b"nip44-v2", ecdh_x(secret_hex, peer_hex))
|
||||
okm = hkdf_expand(ck, nonce, 76)
|
||||
key, iv, mac_key = okm[:32], okm[32:44], okm[44:76]
|
||||
assert hmac.compare_digest(hmac.new(mac_key, nonce + ct, hashlib.sha256).digest(), mac), "bad MAC"
|
||||
padded = chacha20(key, iv, ct)
|
||||
ln = int.from_bytes(padded[:2], "big")
|
||||
body = padded[2:2 + ln] if ln else padded[6:6 + int.from_bytes(padded[2:6], "big")]
|
||||
return body.decode()
|
||||
|
||||
|
||||
# ── nostr events ─────────────────────────────────────────────────────────────
|
||||
|
||||
def event_id(pubkey_hex: str, created_at: int, kind: int, tags, content: str) -> str:
|
||||
serialized = json.dumps([0, pubkey_hex, created_at, kind, tags, content], separators=(",", ":"))
|
||||
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||
|
||||
|
||||
def sign_event(secret_hex: str, event: dict) -> dict:
|
||||
eid = event_id(event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"])
|
||||
ev = dict(event)
|
||||
ev["id"] = eid
|
||||
ev["sig"] = bip340_sign(bytes.fromhex(eid), int(secret_hex, 16), os.urandom(32)).hex()
|
||||
return ev
|
||||
|
||||
|
||||
# ── the client session ────────────────────────────────────────────────────────
|
||||
|
||||
def compact(d) -> str:
|
||||
return json.dumps(d, separators=(",", ":"))
|
||||
|
||||
|
||||
async def run(relay: str):
|
||||
client_secret = os.urandom(32).hex()
|
||||
client_secret_int = int(client_secret, 16) % N
|
||||
client_pub_hex = _mul(client_secret_int, G)[0].to_bytes(32, "big").hex()
|
||||
pair_secret = secrets.token_hex(16)
|
||||
nonce = secrets.token_hex(8)
|
||||
|
||||
uri = (
|
||||
f"nostrconnect://{client_pub_hex}"
|
||||
f"?relay={urllib.parse.quote(relay, safe='')}"
|
||||
f"&secret={pair_secret}"
|
||||
f"&name=Archipelago+Test+Client"
|
||||
)
|
||||
|
||||
print(f"· client key : {client_pub_hex}")
|
||||
print(f"· relay : {relay}")
|
||||
print()
|
||||
print("Scan this QR with: Companion → hub (3-finger) → Remote Signer → Scan pairing QR")
|
||||
print()
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
except ImportError:
|
||||
print(uri)
|
||||
|
||||
print()
|
||||
print("Waiting for the phone to pair (connect, ack, get_public_key, sign_event)…")
|
||||
|
||||
async with websockets.connect(relay, max_size=2**22) as ws:
|
||||
await ws.send(compact(["REQ", "test", {"kinds": [24133], "#p": [client_pub_hex], "since": int(time.time()) - 60}]))
|
||||
|
||||
signer_pub = None
|
||||
acked = False
|
||||
requests = []
|
||||
|
||||
def send_frame(content: dict):
|
||||
assert signer_pub is not None
|
||||
ev = {
|
||||
"pubkey": client_pub_hex,
|
||||
"created_at": int(time.time()),
|
||||
"kind": 24133,
|
||||
"tags": [["p", signer_pub]],
|
||||
"content": nip44_encrypt(client_secret, signer_pub, compact(content)),
|
||||
}
|
||||
return asyncio.ensure_future(ws.send(compact(["EVENT", sign_event(client_secret, ev)])))
|
||||
|
||||
async def request(method, params, rid):
|
||||
send_frame({"id": rid, "method": method, "params": params})
|
||||
|
||||
timeout = time.time() + 120
|
||||
got_pubkey = None
|
||||
signed_event = None
|
||||
|
||||
while time.time() < timeout:
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=timeout - time.time())
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
break
|
||||
arr = json.loads(raw)
|
||||
if not isinstance(arr, list) or len(arr) < 3 or arr[0] != "EVENT":
|
||||
continue
|
||||
ev = arr[2]
|
||||
if ev.get("kind") != 24133 or ev.get("pubkey") == client_pub_hex:
|
||||
continue
|
||||
author = ev["pubkey"]
|
||||
try:
|
||||
msg = json.loads(nip44_decrypt(client_secret, author, ev["content"]))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if "method" in msg and msg["method"] == "connect":
|
||||
params = msg.get("params", [])
|
||||
if params and params[0] == author and (len(params) < 2 or params[1] == pair_secret):
|
||||
signer_pub = author
|
||||
print(f"✓ phone paired — signer pubkey {author[:16]}…")
|
||||
send_frame({"id": msg["id"], "result": "ack"})
|
||||
acked = True
|
||||
await asyncio.sleep(0.5)
|
||||
await request("get_public_key", [], nonce + "-gpk")
|
||||
else:
|
||||
print("✗ phone sent connect but the secret didn't match")
|
||||
return 1
|
||||
continue
|
||||
|
||||
if "result" in msg or "error" in msg:
|
||||
rid = msg.get("id", "")
|
||||
if "error" in msg:
|
||||
print(f"✗ error for {rid}: {msg['error']}")
|
||||
if rid.endswith("-sign"):
|
||||
return 1
|
||||
continue
|
||||
result = msg.get("result", "")
|
||||
if rid.endswith("-gpk"):
|
||||
got_pubkey = result
|
||||
print(f"✓ get_public_key → {result}")
|
||||
await request(
|
||||
"sign_event",
|
||||
[compact({
|
||||
"kind": 1,
|
||||
"content": "Hello from the Archipelago NIP-46 test client — approved by hand.",
|
||||
"tags": [],
|
||||
"created_at": int(time.time()),
|
||||
})],
|
||||
nonce + "-sign",
|
||||
)
|
||||
elif rid.endswith("-sign"):
|
||||
signed_event = json.loads(result)
|
||||
print(f"✓ sign_event → signed event {signed_event.get('id', '')[:16]}…")
|
||||
break
|
||||
|
||||
if not acked:
|
||||
print("✗ the phone never connected (2-minute timeout)")
|
||||
return 1
|
||||
if got_pubkey is None or got_pubkey != signer_pub:
|
||||
print("✗ get_public_key missing or mismatched")
|
||||
return 1
|
||||
if signed_event is None:
|
||||
return 1
|
||||
|
||||
ev = signed_event
|
||||
expected_id = event_id(ev["pubkey"], ev["created_at"], ev["kind"], ev["tags"], ev["content"])
|
||||
ok_id = expected_id == ev["id"]
|
||||
ok_sig = bip340_verify(bytes.fromhex(expected_id), bytes.fromhex(ev["pubkey"]), bytes.fromhex(ev["sig"]))
|
||||
print(f"· event id correct : {ok_id}")
|
||||
print(f"· schnorr signature: {'VERIFIED ✓' if ok_sig else 'INVALID ✗'}")
|
||||
if ok_id and ok_sig:
|
||||
print()
|
||||
print("END-TO-END PASS — the companion signed as the identity the phone holds,")
|
||||
print("and the signature verifies under an independent BIP-340 implementation.")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--relay", default="wss://relay.damus.io", help="any nostr relay both devices can reach")
|
||||
args = ap.parse_args()
|
||||
sys.exit(asyncio.run(run(args.relay)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.8-alpha (2026-09-01)
|
||||
|
||||
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
|
||||
|
||||
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
|
||||
|
||||
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
|
||||
|
||||
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
|
||||
|
||||
## v1.8.7-alpha (2026-08-31)
|
||||
|
||||
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
|
||||
|
||||
- **The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.
|
||||
|
||||
- **Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
|
||||
|
||||
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
|
||||
|
||||
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
|
||||
|
||||
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
|
||||
|
||||
## v1.8.6-alpha (2026-08-31)
|
||||
|
||||
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
|
||||
|
||||
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
|
||||
|
||||
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
|
||||
|
||||
## v1.8.5-alpha (2026-08-30)
|
||||
|
||||
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
|
||||
|
||||
+391
-355
@@ -11,16 +11,47 @@
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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",
|
||||
"id": "adguardhome",
|
||||
"title": "AdGuard Home",
|
||||
"version": "v0.107.55",
|
||||
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
|
||||
"icon": "",
|
||||
"author": "AdGuard",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
|
||||
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
@@ -35,76 +66,16 @@
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.3",
|
||||
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "botfights",
|
||||
@@ -132,127 +103,46 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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": "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="
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.3",
|
||||
"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": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.10.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/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"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": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
@@ -299,54 +189,87 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
"8083:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
"/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": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
"3001:3000",
|
||||
"2222:22"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
"/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": "immich",
|
||||
"title": "Immich",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
|
||||
"icon": "/assets/img/app-icons/grafana.png",
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "homeassistant",
|
||||
@@ -370,11 +293,209 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.10.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/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"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": "ollama",
|
||||
"title": "Ollama",
|
||||
"version": "0.5.4",
|
||||
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama",
|
||||
"category": "community",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
@@ -382,26 +503,44 @@
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3000:3000"
|
||||
"9000:9000"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||
"/var/lib/archipelago/portainer:/data",
|
||||
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": [
|
||||
"GF_PATHS_DATA=/var/lib/grafana",
|
||||
"GF_USERS_ALLOW_SIGN_UP=false"
|
||||
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -433,51 +572,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"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": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
@@ -507,82 +601,24 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"2342:2342"
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||
],
|
||||
"env": [
|
||||
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
|
||||
"PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
app:
|
||||
id: adguardhome
|
||||
name: AdGuard Home
|
||||
version: v0.107.55
|
||||
upstream:
|
||||
kind: github
|
||||
repo: AdguardTeam/AdGuardHome
|
||||
description: >-
|
||||
Network-wide ad and tracker blocking: a DNS server that filters every
|
||||
device on your LAN, with a web console for rules and client management.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 3030
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
|
||||
# node, and both being installable means the host ports must not
|
||||
# collide (the orchestrator refuses/loads warn on overlap).
|
||||
# open: the setup wizard and admin console carry AdGuard Home's own
|
||||
# login; the gate fronts the port (TLS, header fixes) without a
|
||||
# second cookie challenge.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
AdGuard Home enforces its own admin login on the console, and the
|
||||
first-run wizard must answer before any account exists.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: udp
|
||||
# none: plain DNS must answer every unauthenticated query from LAN
|
||||
# devices — a login page in front of :53 breaks every client on the
|
||||
# network by design.
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Plain DNS answers unauthenticated by protocol: resolvers and clients
|
||||
send queries directly; a login challenge would make DNS unreachable.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
DNS-over-TCP fallback (truncated responses, zone transfers); same
|
||||
protocol-level requirement as the UDP port.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/adguardhome
|
||||
target: /opt/adguardhome
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3030
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Admin console
|
||||
description: AdGuard Home web console
|
||||
type: ui
|
||||
port: 3030
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: AdGuard
|
||||
category: networking
|
||||
repo: https://github.com/AdguardTeam/AdGuardHome
|
||||
tier: optional
|
||||
@@ -15,11 +15,6 @@ app:
|
||||
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
|
||||
category: money
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
tier: optional
|
||||
|
||||
container:
|
||||
# Built from the upstream Dockerfile at the tip of main, 18 commits past
|
||||
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,39 +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 ./
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/wallet && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV WALLET_STORAGE=/app/wallet
|
||||
ENV DWN_ENDPOINT=http://web5-dwn:3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,35 +0,0 @@
|
||||
# DID Wallet
|
||||
|
||||
Web5 wallet with Decentralized Identifier (DID) support.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# From the apps directory
|
||||
./build.sh did-wallet
|
||||
|
||||
# Or manually
|
||||
cd did-wallet
|
||||
docker build -t archipelago/did-wallet:latest .
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd did-wallet
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
- **8083**: Web UI (dev: 18083)
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
docker run -p 8083:8080 \
|
||||
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
|
||||
-e DWN_ENDPOINT=http://localhost:13000 \
|
||||
archipelago/did-wallet:latest
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
app:
|
||||
id: did-wallet
|
||||
name: Web5 DID Wallet
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
|
||||
|
||||
container:
|
||||
image: archipelago/did-wallet:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: did-wallet
|
||||
|
||||
ports:
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/did-wallet
|
||||
target: /app/wallet
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- WALLET_STORAGE=/app/wallet
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web5_integration:
|
||||
did_support: true
|
||||
wallet_functionality: true
|
||||
bitcoin_integration: true
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "did-wallet",
|
||||
"version": "1.0.0",
|
||||
"description": "Web5 DID Wallet for Archipelago",
|
||||
"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,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DID Wallet</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Web5 DID Wallet</h1>
|
||||
<p>Decentralized Identity Wallet for Archipelago</p>
|
||||
<div id="app">
|
||||
<p>Wallet interface coming soon...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'did-wallet' });
|
||||
});
|
||||
|
||||
// Wallet API endpoints
|
||||
app.get('/api/wallet/info', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
wallet: {
|
||||
dids: [],
|
||||
balance: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/wallet/did/create', async (req, res) => {
|
||||
// Placeholder for DID creation
|
||||
res.json({
|
||||
status: 'ok',
|
||||
did: 'did:key:placeholder'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`DID Wallet listening on port ${port}`);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Lightning Stack - uses official image
|
||||
FROM lightninglabs/lightning-stack:v0.12.0
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,85 +0,0 @@
|
||||
app:
|
||||
id: lightning-stack
|
||||
name: Lightning Stack
|
||||
version: 0.12.0
|
||||
# No public listing exists for lightninglabs/lightning-stack (checked
|
||||
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
|
||||
# so this one is tracked by hand.
|
||||
upstream:
|
||||
kind: manual
|
||||
url: no public listing for lightninglabs/lightning-stack — verify by hand
|
||||
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
|
||||
|
||||
container:
|
||||
image: lightninglabs/lightning-stack:v0.12.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=24.0"
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: lightning-stack
|
||||
|
||||
ports:
|
||||
- host: 9738
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
|
||||
- host: 10010
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
|
||||
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
|
||||
- host: 8091
|
||||
container: 8080
|
||||
protocol: tcp # REST/Web UI
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND REST, authenticated by macaroon over TLS. A browser login page would break
|
||||
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lightning-stack
|
||||
target: /root/.lightning
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-core
|
||||
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
|
||||
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
|
||||
- NETWORK=mainnet
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /v1/getinfo
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
lightning_integration:
|
||||
channel_management: true
|
||||
payment_routing: true
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 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 8080
|
||||
|
||||
ENV MORPHOS_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,55 +0,0 @@
|
||||
app:
|
||||
id: morphos-server
|
||||
name: MorphOS Server
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: MorphOS server platform. Decentralized application server.
|
||||
|
||||
container:
|
||||
image: archipelago/morphos-server:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 5Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: morphos-server
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/morphos-server
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MORPHOS_ENV=production
|
||||
- MORPHOS_DATA_DIR=/app/data
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "morphos-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MorphOS server platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
|
||||
});
|
||||
|
||||
// API endpoints
|
||||
app.get('/api/info', (req, res) => {
|
||||
res.json({
|
||||
name: 'MorphOS Server',
|
||||
version: '1.0.0',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`MorphOS Server listening on port ${port}`);
|
||||
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/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"]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
app:
|
||||
id: nginx-proxy-manager
|
||||
name: Nginx Proxy Manager
|
||||
version: 2.12.1
|
||||
upstream:
|
||||
kind: github
|
||||
repo: NginxProxyManager/nginx-proxy-manager
|
||||
description: >-
|
||||
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
||||
On a node, this manages its admin UI and upstream configuration — the
|
||||
proxy's own :80/:443 listeners are not published (the node's web server
|
||||
owns those ports).
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8081
|
||||
container: 81
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# open, not gated: NPM carries a complete admin login of its own. The
|
||||
# gate still fronts the port (TLS on the same port, header fixes, retry
|
||||
# page, Tor) without putting a cookie challenge in front of it.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Nginx Proxy Manager enforces its own admin account on every page;
|
||||
the initial setup wizard also has to answer before any account exists.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:81
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Admin UI
|
||||
description: Nginx Proxy Manager admin interface
|
||||
type: ui
|
||||
port: 8081
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: Nginx Proxy Manager
|
||||
category: networking
|
||||
icon: /assets/img/app-icons/nginx.svg
|
||||
repo: https://github.com/NginxProxyManager/nginx-proxy-manager
|
||||
tier: optional
|
||||
@@ -0,0 +1,63 @@
|
||||
app:
|
||||
id: ollama
|
||||
name: Ollama
|
||||
version: 0.5.4
|
||||
upstream:
|
||||
kind: github
|
||||
repo: ollama/ollama
|
||||
description: >-
|
||||
Run large language models locally. Download and run AI models like
|
||||
Llama, Mistral on your own hardware — served on the node's loopback for
|
||||
the AI assistant (Settings → Claude Auth → model backend), never exposed
|
||||
to the network.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/ollama:latest
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
# No memory limit: models are sized by the disk allowance below, and a
|
||||
# RAM ceiling would just OOM-kill long inferences.
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 11434
|
||||
container: 11434
|
||||
protocol: tcp
|
||||
# local: Ollama's REST API is consumed by the node's own assistant over
|
||||
# loopback — never externally reachable, so no gate, no TLS, and no
|
||||
# login surface exist at all.
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/ollama
|
||||
target: /root/.ollama
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:11434
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
author: Ollama
|
||||
category: community
|
||||
icon: /assets/img/app-icons/ollama.png
|
||||
repo: https://github.com/ollama/ollama
|
||||
tier: optional
|
||||
@@ -0,0 +1,78 @@
|
||||
app:
|
||||
id: tailscale
|
||||
name: Tailscale
|
||||
version: 1.78.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: tailscale/tailscale
|
||||
description: Zero-config VPN with WireGuard mesh networking.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/tailscale:stable
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
# Mirrors the legacy curated install exactly: tailscaled in userspace
|
||||
# networking (no host TUN device needed — the rootless container cannot
|
||||
# have one anyway), then `tailscale web` serving the console on :8240 as
|
||||
# plain HTTP the app gate can front (TLS on the same port via the node
|
||||
# certificate, framing-header fixes, retry page, Tor).
|
||||
entrypoint: ["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"]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8240
|
||||
container: 8240
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# open, not gated: the web console requires the tailnet's own login for
|
||||
# every administrative action — the gate fronts the port without adding
|
||||
# a second login in front of it.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Tailscale's web console authenticates against the tailnet account for
|
||||
all administrative actions; the node's cookie challenge would be a
|
||||
second, redundant login.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/tailscale
|
||||
target: /var/lib/tailscale
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- TS_STATE_DIR=/var/lib/tailscale
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8240
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web console
|
||||
description: Tailscale web console
|
||||
type: ui
|
||||
port: 8240
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: Tailscale
|
||||
category: networking
|
||||
icon: /assets/img/app-icons/tailscale.webp
|
||||
repo: https://github.com/tailscale/tailscale
|
||||
tier: recommended
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"version": 2,
|
||||
"updated": "2026-04-22T00:00:00Z",
|
||||
"registry": "source.archipelago-foundation.org/lfg2025",
|
||||
"featured": {
|
||||
"id": "indeedhub",
|
||||
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||
"headline": "Stream Sovereignty",
|
||||
"description": "Bitcoin documentaries with Nostr identity.",
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"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",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4",
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.3",
|
||||
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "botfights",
|
||||
"title": "BotFights",
|
||||
"version": "1.2.11",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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": "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="
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.10.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/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"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": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint Guardian",
|
||||
"version": "0.10.0",
|
||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "fedimint-clientd",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
|
||||
"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": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2026.7.3",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8123:8123"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/home-assistant:/config"
|
||||
],
|
||||
"env": [
|
||||
"TZ=UTC"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"title": "Tailscale",
|
||||
"version": "1.78.0",
|
||||
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||
"author": "Tailscale",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/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 & 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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"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": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-hosted uptime monitoring.",
|
||||
"icon": "/assets/img/app-icons/uptime-kuma.webp",
|
||||
"author": "Uptime Kuma",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.5-alpha"
|
||||
version = "1.8.8-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.5-alpha"
|
||||
version = "1.8.8-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -145,6 +145,21 @@ impl ApiHandler {
|
||||
/// URL so the App Store still renders on nodes that haven't persisted
|
||||
/// a registry config yet. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||
// The daemon already refreshes and verifies releases/app-catalog.json.
|
||||
// Serve that release-root-anchored cache first so a newly published app
|
||||
// appears immediately, without a frontend release. The old external UI
|
||||
// catalog below is emergency compatibility only; it must never override
|
||||
// a healthy signed catalog (Cuprate was invisible for exactly that reason).
|
||||
if let Ok(body) =
|
||||
crate::container::app_catalog::verified_catalog_body(&self.config.data_dir).await
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.body(hyper::Body::from(body))?);
|
||||
}
|
||||
|
||||
let mut upstreams: Vec<String> = Vec::new();
|
||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||
{
|
||||
|
||||
@@ -558,6 +558,11 @@ impl RpcHandler {
|
||||
self.handle_fips_remove_seed_anchor(&p).await
|
||||
}
|
||||
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||
"fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await,
|
||||
"fips.ssh-over-mesh.set" => {
|
||||
let p = params.unwrap_or(serde_json::json!({}));
|
||||
self.handle_fips_ssh_over_mesh_set(&p).await
|
||||
}
|
||||
|
||||
// System updates
|
||||
"update.check" => self.handle_update_check().await,
|
||||
|
||||
@@ -261,4 +261,51 @@ impl RpcHandler {
|
||||
}).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The SSH-over-mesh toggle state plus sshd preflights (the card explains
|
||||
/// the rule instead of gating on it — see ssh_mesh.rs).
|
||||
pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result<serde_json::Value> {
|
||||
let state = fips::ssh_mesh::load(&self.config.data_dir).await;
|
||||
let preflights = fips::ssh_mesh::preflights().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": state.enabled,
|
||||
"sources": state.sources,
|
||||
"scope": if state.sources.is_empty() { "any" } else { "list" },
|
||||
"preflights": preflights,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` —
|
||||
/// an empty/absent source list opens port 22 to every mesh peer (the UI
|
||||
/// confirms that explicitly before calling with it).
|
||||
pub(super) async fn handle_fips_ssh_over_mesh_set(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?;
|
||||
let sources: Vec<String> = params
|
||||
.get("sources")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (state, outcome) =
|
||||
fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?;
|
||||
let preflights = fips::ssh_mesh::preflights().await;
|
||||
Ok(serde_json::json!({
|
||||
"enabled": state.enabled,
|
||||
"sources": state.sources,
|
||||
"scope": if state.sources.is_empty() { "any" } else { "list" },
|
||||
"applied": outcome.applied,
|
||||
"removed": outcome.removed,
|
||||
"reloaded": outcome.reloaded,
|
||||
"preflights": preflights,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
|
||||
//! publisher side never breaks older nodes.
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -194,6 +195,27 @@ fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
/// Return the cached catalog bytes only when they carry a signature anchored
|
||||
/// to the release root. This is the browser App Store's source: newly signed
|
||||
/// apps must appear without waiting for a frontend OTA, while unsigned or
|
||||
/// self-signed registry data must never become an install button.
|
||||
pub async fn verified_catalog_body(data_dir: &Path) -> anyhow::Result<String> {
|
||||
let path = data_dir.join(APP_CATALOG_FILE);
|
||||
let body = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("read signed app catalog {}", path.display()))?;
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
match crate::trust::verify_detached(&raw)? {
|
||||
crate::trust::SignatureStatus::Verified { anchored: true, .. } => Ok(body),
|
||||
crate::trust::SignatureStatus::Verified {
|
||||
anchored: false, ..
|
||||
} => {
|
||||
anyhow::bail!("app catalog signer is not anchored to the release root")
|
||||
}
|
||||
crate::trust::SignatureStatus::Unsigned => anyhow::bail!("app catalog is unsigned"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary image for an app per the remote catalog, if covered.
|
||||
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).and_then(|e| e.image)
|
||||
@@ -641,4 +663,27 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// The signed-catalog body served to the browser must be the anchored,
|
||||
// release-root-verified bytes — and nothing else. Unsigned caches (the
|
||||
// migration-window form) and self-consistent-but-unanchored signatures
|
||||
// must both be refused so a tampered mirror can never become an install
|
||||
// button (same posture as the OTA manifest supply-chain gate).
|
||||
#[tokio::test]
|
||||
async fn verified_catalog_body_rejects_unsigned_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache(
|
||||
dir.path(),
|
||||
r#"{"schema":1,"apps":{"demo":{"version":"1"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = verified_catalog_body(dir.path()).await.unwrap_err();
|
||||
assert!(err.to_string().contains("unsigned"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_catalog_body_rejects_missing_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(verified_catalog_body(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,12 @@ impl DockerPackageScanner {
|
||||
|
||||
// Get metadata for this app
|
||||
let metadata = get_app_metadata(&app_id);
|
||||
// Manifest-owned metadata (icon) wins over the static table: the
|
||||
// manifest is what the catalog signed and what the App Store shows,
|
||||
// so it is also what an installed tile must render.
|
||||
let manifest_icon = real_manifest_metadata(&app_id)
|
||||
.and_then(|m| m.get("icon").and_then(|v| v.as_str()).map(str::to_string))
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
// Resolve UI address: separate UI containers > static map > dynamic ports
|
||||
let lan_address = if app_id == "netbird" {
|
||||
@@ -191,7 +197,7 @@ impl DockerPackageScanner {
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
icon: metadata.icon.clone(),
|
||||
icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: app_id.clone(),
|
||||
@@ -211,28 +217,34 @@ impl DockerPackageScanner {
|
||||
author: Some("Archipelago".to_string()),
|
||||
website: lan_address.clone(),
|
||||
tier: Some(metadata.tier.to_string()),
|
||||
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||
interfaces: {
|
||||
// `ui` is no longer implied by a published port: a
|
||||
// headless backend with an exposed port is a service,
|
||||
// not a launchable app. ui_detection consults the
|
||||
// manifest declaration first, then HTTP-probes the
|
||||
// port. Addresses stay present either way so the
|
||||
// Services tab can still show where a backend lives.
|
||||
// port. A DECLARED UI classifies the app as launchable
|
||||
// even when no reachable address was confirmed this
|
||||
// scan — the launch button falls back to the static
|
||||
// port map, and burying a manifest-declared UI app
|
||||
// (Alby Hub) in Services because a probe missed was
|
||||
// exactly the classification bug this fixes.
|
||||
let has_ui = super::ui_detection::has_web_ui(
|
||||
&app_id,
|
||||
lan_address.as_deref(),
|
||||
package_state == PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: has_ui.then(|| "true".to_string()),
|
||||
tor_config: tor_address.clone(),
|
||||
lan_config: None,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if lan_address.is_some() || tor_address.is_some() || has_ui {
|
||||
Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: has_ui.then(|| "true".to_string()),
|
||||
tor_config: tor_address.clone(),
|
||||
lan_config: None,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
},
|
||||
available_update,
|
||||
@@ -322,6 +334,47 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
|
||||
&& right.chars().all(|c| c.is_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Raw `metadata` block of an installed app's real manifest — catalog overlay
|
||||
/// first (origin-wins), disk manifest as fallback. Kept as raw JSON because
|
||||
/// the typed `AppManifest` deliberately does not model `metadata`, yet its
|
||||
/// `icon` is what makes an installed app's tile render the right icon on
|
||||
/// every surface (My Apps, Services, launcher, companion) instead of the
|
||||
/// generic A-mark — the exact regression Cuprate exposed on install.
|
||||
fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
|
||||
for (id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
if id == app_id {
|
||||
return value.get("app").and_then(|a| a.get("metadata")).cloned();
|
||||
}
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
candidates.push(
|
||||
std::path::PathBuf::from(dir)
|
||||
.join("../apps")
|
||||
.join(app_id)
|
||||
.join("manifest.yml"),
|
||||
);
|
||||
}
|
||||
candidates.push(
|
||||
std::path::PathBuf::from("/opt/archipelago/apps")
|
||||
.join(app_id)
|
||||
.join("manifest.yml"),
|
||||
);
|
||||
for path in candidates {
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else {
|
||||
continue;
|
||||
};
|
||||
let meta = value.get("app").and_then(|a| a.get("metadata")).cloned();
|
||||
if meta.is_some() {
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
let mut meta = match app_id {
|
||||
"bitcoin-core" => AppMetadata {
|
||||
|
||||
@@ -163,7 +163,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
|
||||
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
|
||||
"searxng" => Some("SEARXNG_IMAGE"),
|
||||
"cryptpad" => Some("CRYPTPAD_IMAGE"),
|
||||
"filebrowser" => Some("FILEBROWSER_IMAGE"),
|
||||
"nginx-proxy-manager" => Some("NPM_IMAGE"),
|
||||
"portainer" => Some("PORTAINER_IMAGE"),
|
||||
@@ -178,18 +177,10 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
|
||||
// Nostr / VPN
|
||||
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
|
||||
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
|
||||
"fips" => Some("FIPS_IMAGE"),
|
||||
|
||||
// Immich (primary = server)
|
||||
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
||||
|
||||
// Penpot (primary = frontend)
|
||||
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
|
||||
|
||||
// AI
|
||||
"routstr" => Some("ROUTSTR_IMAGE"),
|
||||
|
||||
// Networking
|
||||
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
|
||||
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
||||
@@ -341,13 +332,6 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
("immich_redis", "REDIS_IMAGE"),
|
||||
("immich_server", "IMMICH_SERVER_IMAGE"),
|
||||
],
|
||||
"penpot" | "penpot-frontend" => vec![
|
||||
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
|
||||
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
|
||||
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
|
||||
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
|
||||
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||
],
|
||||
"netbird" => vec![
|
||||
("netbird", "NETBIRD_PROXY_IMAGE"),
|
||||
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
|
||||
|
||||
@@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// SSH-over-mesh rides every config install so the on-state survives
|
||||
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
|
||||
// this module owns the 90-ssh.nft slot exclusively).
|
||||
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
|
||||
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
|
||||
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod dial;
|
||||
pub mod endpoints;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod ssh_mesh;
|
||||
pub mod telemetry;
|
||||
pub mod update;
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! SSH over the FIPS mesh — a first-class settings toggle.
|
||||
//!
|
||||
//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/
|
||||
//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins
|
||||
//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct
|
||||
//! by default — but the user asked to be able to SSH their node from Termux
|
||||
//! over the phone's FIPS mesh instead of keeping a second VPN around for it,
|
||||
//! and the mesh path already works end-to-end (verified live: the connect
|
||||
//! reaches fips0 and gets a RST from the node).
|
||||
//!
|
||||
//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly
|
||||
//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this
|
||||
//! feature can never fight over the same slot:
|
||||
//!
|
||||
//! * toggle OFF → drop-in removed, port 22 refused again
|
||||
//! * toggle ON → drop-in written on every toggle change AND on every
|
||||
//! daemon config install (upgrade, reconnect, self-heal),
|
||||
//! so the on-state survives reinstalls idempotently
|
||||
//! * scope → "any" (every mesh peer — a real exposure, gated in the
|
||||
//! UI behind an explicit confirmation) or an explicit list
|
||||
//! of mesh addresses
|
||||
//!
|
||||
//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to
|
||||
//! `config.rs`, and the sshd process itself is entirely the operator's.
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// On-disk state under the archipelago data dir. Absent file = disabled,
|
||||
/// which is the safe default for every node that never touched the toggle.
|
||||
const STATE_FILE: &str = "fips-ssh-over-mesh.json";
|
||||
|
||||
/// The drop-in slot this module owns. 90 sorts after the daemon's own
|
||||
/// drop-ins (80/85) so a human reading the directory sees the deliberate
|
||||
/// order; the include order does not change semantics for plain accepts.
|
||||
pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft";
|
||||
|
||||
/// The hardening baseline this drop-in hangs off. Same file `config.rs`
|
||||
/// reloads after its own drop-ins.
|
||||
const FIPS_NFT: &str = "/etc/fips/fips.nft";
|
||||
|
||||
/// Persisted toggle state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SshMeshState {
|
||||
/// Whether port 22 is allowed through the fips0 baseline at all.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh
|
||||
/// peer. Kept as strings as-entered but validated as IPv6 on save.
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
fn state_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(STATE_FILE)
|
||||
}
|
||||
|
||||
/// Load the persisted state. Missing file = disabled, no sources — never an
|
||||
/// error, so a fresh node and a deleted file both mean "off".
|
||||
pub async fn load(data_dir: &Path) -> SshMeshState {
|
||||
match tokio::fs::read_to_string(state_path(data_dir)).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => SshMeshState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and normalise an operator-supplied source list. Every entry must
|
||||
/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) —
|
||||
/// anything else is refused with the offending entry named, so a typo can
|
||||
/// never silently narrow or widen the rule.
|
||||
pub fn validate_sources(raw: &[String]) -> Result<Vec<String>> {
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for entry in raw {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let addr: Ipv6Addr = trimmed
|
||||
.parse()
|
||||
.with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?;
|
||||
out.push(addr.to_string());
|
||||
}
|
||||
out.dedup();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render the nft drop-in for a state. The rule shape mirrors the interim
|
||||
/// manual unblock from the field notes (`ip6 saddr <ula> tcp dport 22
|
||||
/// accept`) — an unrestricted rule is the same statement without the saddr.
|
||||
pub fn render_dropin(state: &SshMeshState) -> String {
|
||||
let mut out = String::from(
|
||||
"# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\
|
||||
# Allows sshd (port 22) through the fips0 default-deny inbound\n\
|
||||
# baseline. Remove = refused again; never edit 80/85-* by hand.\n",
|
||||
);
|
||||
if state.sources.is_empty() {
|
||||
out.push_str("tcp dport 22 accept\n");
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"ip6 saddr {{ {} }} tcp dport 22 accept\n",
|
||||
state.sources.join(", ")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write or remove the drop-in to match the persisted state, then reload the
|
||||
/// baseline so the change is live immediately. Returns whether a reload was
|
||||
/// attempted and succeeded — a node without the hardening baseline has
|
||||
/// nothing to reload (port 22 is governed by sshd and the host firewall
|
||||
/// there), which is reported rather than treated as failure.
|
||||
pub async fn reconcile(data_dir: &Path) -> Result<ReconcileOutcome> {
|
||||
let state = load(data_dir).await;
|
||||
|
||||
if !state.enabled {
|
||||
let removed = remove_dropin().await?;
|
||||
let reloaded = reload_nft().await;
|
||||
return Ok(ReconcileOutcome {
|
||||
applied: false,
|
||||
removed,
|
||||
reloaded,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure /etc/fips/fips.d exists, exactly like config::install.
|
||||
let out = Command::new("sudo")
|
||||
.args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"])
|
||||
.output()
|
||||
.await
|
||||
.context("sudo install -d /etc/fips/fips.d")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"sudo install -d /etc/fips/fips.d failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let dropin = render_dropin(&state);
|
||||
let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&stage, &dropin)
|
||||
.await
|
||||
.context("stage ssh nft drop-in")?;
|
||||
let install = Command::new("sudo")
|
||||
.args(["install", "-m", "0644"])
|
||||
.arg(&stage)
|
||||
.arg(DROPIN_PATH)
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&stage).await;
|
||||
let install = install?;
|
||||
if !install.status.success() {
|
||||
anyhow::bail!(
|
||||
"install {} failed: {}",
|
||||
DROPIN_PATH,
|
||||
String::from_utf8_lossy(&install.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let reloaded = reload_nft().await;
|
||||
Ok(ReconcileOutcome {
|
||||
applied: true,
|
||||
removed: false,
|
||||
reloaded,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReconcileOutcome {
|
||||
/// The allow rule is in place.
|
||||
pub applied: bool,
|
||||
/// A previously-written drop-in was removed this call.
|
||||
pub removed: bool,
|
||||
/// The hardening baseline existed and `nft -f` succeeded.
|
||||
pub reloaded: bool,
|
||||
}
|
||||
|
||||
async fn remove_dropin() -> Result<bool> {
|
||||
match tokio::fs::try_exists(DROPIN_PATH).await {
|
||||
Ok(true) => {}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
let out = Command::new("sudo")
|
||||
.args(["rm", "-f", DROPIN_PATH])
|
||||
.output()
|
||||
.await
|
||||
.context("sudo rm 90-ssh.nft")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"removing {} failed: {}",
|
||||
DROPIN_PATH,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Reload the hardening baseline. Best-effort in the same spirit as
|
||||
/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed
|
||||
/// reload is Ok(false) with a warn, never an error — the drop-in is on disk
|
||||
/// either way and the next daemon install reloads it.
|
||||
async fn reload_nft() -> bool {
|
||||
match tokio::fs::try_exists(FIPS_NFT).await {
|
||||
Ok(true) => {}
|
||||
_ => return false,
|
||||
}
|
||||
match Command::new("sudo")
|
||||
.args(["nft", "-f", FIPS_NFT])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.status.success() => true,
|
||||
Ok(out) => {
|
||||
tracing::warn!(
|
||||
"ssh-over-mesh: nft reload failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ssh-over-mesh: nft reload failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist new state and reconcile immediately. Validation happens here so
|
||||
/// an invalid source list can never reach disk, and reconcile reads back
|
||||
/// exactly what was saved.
|
||||
pub async fn set(
|
||||
data_dir: &Path,
|
||||
enabled: bool,
|
||||
sources: &[String],
|
||||
) -> Result<(SshMeshState, ReconcileOutcome)> {
|
||||
let state = SshMeshState {
|
||||
enabled,
|
||||
sources: validate_sources(sources)?,
|
||||
};
|
||||
tokio::fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
|
||||
tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?)
|
||||
.await
|
||||
.with_context(|| format!("write {}", state_path(data_dir).display()))?;
|
||||
let outcome = reconcile(data_dir).await?;
|
||||
Ok((state, outcome))
|
||||
}
|
||||
|
||||
/// Preflights surfaced in the settings card. None of these gate the toggle —
|
||||
/// they explain it: writing the rule on a node whose sshd doesn't listen on
|
||||
/// IPv6 simply has no effect until sshd does, and the card says so instead of
|
||||
/// the user discovering it as a silent connection failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SshPreflights {
|
||||
/// ssh.service (or sshd.service) is active.
|
||||
pub sshd_active: bool,
|
||||
/// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`).
|
||||
/// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it.
|
||||
pub sshd_ipv6_listen: bool,
|
||||
/// sshd_config's PasswordAuthentication (last directive wins, includes
|
||||
/// after the main file). None = not found / unreadable.
|
||||
pub password_auth: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn preflights() -> SshPreflights {
|
||||
SshPreflights {
|
||||
sshd_active: sshd_active().await,
|
||||
sshd_ipv6_listen: sshd_ipv6_listen().await,
|
||||
password_auth: password_auth_enabled().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sshd_active() -> bool {
|
||||
for unit in ["ssh", "sshd"] {
|
||||
if let Ok(out) = Command::new("systemctl")
|
||||
.args(["is-active", "--quiet", unit])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if out.status.success() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn sshd_ipv6_listen() -> bool {
|
||||
let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else {
|
||||
return false;
|
||||
};
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines().any(|line| {
|
||||
let mut cols = line.split_whitespace();
|
||||
// -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4.
|
||||
let _state = cols.next();
|
||||
let _recv = cols.next();
|
||||
let _send = cols.next();
|
||||
match cols.next() {
|
||||
Some(local) => {
|
||||
let port_ok = local.rsplit(':').next() == Some("22");
|
||||
let v6 = local.starts_with("[::]") || local.starts_with('*');
|
||||
port_ok && v6
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn password_auth_enabled() -> Option<bool> {
|
||||
let mut directives: Vec<bool> = Vec::new();
|
||||
if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await {
|
||||
collect_password_auth(&main, &mut directives);
|
||||
}
|
||||
if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await {
|
||||
for path in includes {
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
collect_password_auth(&content, &mut directives);
|
||||
}
|
||||
}
|
||||
}
|
||||
directives.pop()
|
||||
}
|
||||
|
||||
fn collect_password_auth(content: &str, out: &mut Vec<bool>) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") {
|
||||
let rest = rest.trim_start();
|
||||
let value = rest.split_whitespace().next().unwrap_or("");
|
||||
if value.eq_ignore_ascii_case("yes") {
|
||||
out.push(true);
|
||||
} else if value.eq_ignore_ascii_case("no") {
|
||||
out.push(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn glob_sorted(pattern: &str) -> Result<Vec<std::path::PathBuf>> {
|
||||
let dir = std::path::Path::new(pattern)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/"));
|
||||
let prefix = std::path::Path::new(pattern)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.and_then(|n| n.split('.').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(dir)
|
||||
.await
|
||||
.context("read sshd_config.d")?;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with(&prefix) && name.ends_with(".conf") {
|
||||
files.push(entry.path());
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_is_the_default_and_missing_file_is_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(load(dir.path()));
|
||||
assert!(!state.enabled);
|
||||
assert!(state.sources.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_peer_dropin_is_an_unrestricted_accept() {
|
||||
let state = SshMeshState {
|
||||
enabled: true,
|
||||
sources: vec![],
|
||||
};
|
||||
let out = render_dropin(&state);
|
||||
assert!(out.contains("tcp dport 22 accept"));
|
||||
assert!(!out.contains("ip6 saddr"), "no saddr restriction expected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_list_dropin_restricts_to_those_addresses() {
|
||||
let state = SshMeshState {
|
||||
enabled: true,
|
||||
sources: vec![
|
||||
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
|
||||
"fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(),
|
||||
],
|
||||
};
|
||||
let out = render_dropin(&state);
|
||||
assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_must_be_ipv6_and_are_normalised() {
|
||||
let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err();
|
||||
assert!(bad.to_string().contains("192.168.1.5"));
|
||||
|
||||
let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err();
|
||||
assert!(bad.to_string().contains("not-an-address"));
|
||||
|
||||
// Uppercase/whitespace entries normalise to canonical lowercase.
|
||||
let ok = validate_sources(&[
|
||||
" FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(),
|
||||
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
|
||||
String::new(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ok,
|
||||
vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_round_trips_through_disk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = SshMeshState {
|
||||
enabled: true,
|
||||
sources: vec!["fd00::1".to_string()],
|
||||
};
|
||||
std::fs::write(
|
||||
dir.path().join(STATE_FILE),
|
||||
serde_json::to_string(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(load(dir.path()));
|
||||
assert_eq!(loaded, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_validates_before_persisting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let err = rt
|
||||
.block_on(set(dir.path(), true, &["bogus".to_string()]))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("bogus"));
|
||||
// Nothing was persisted.
|
||||
let state = rt.block_on(load(dir.path()));
|
||||
assert!(!state.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_parse_helpers_cover_the_directives() {
|
||||
let mut directives = Vec::new();
|
||||
collect_password_auth(
|
||||
"# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n",
|
||||
&mut directives,
|
||||
);
|
||||
assert_eq!(directives, vec![true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() {
|
||||
assert!(line_listens("[::]:22"));
|
||||
assert!(line_listens("*:22"));
|
||||
assert!(!line_listens("0.0.0.0:22"));
|
||||
assert!(!line_listens("[::]:80"));
|
||||
}
|
||||
|
||||
fn line_listens(local: &str) -> bool {
|
||||
let line = format!("LISTEN 0 128 {local} 0.0.0.0:*");
|
||||
let mut cols = line.split_whitespace();
|
||||
cols.next();
|
||||
cols.next();
|
||||
cols.next();
|
||||
match cols.next() {
|
||||
Some(l) => {
|
||||
let port_ok = l.rsplit(':').next() == Some("22");
|
||||
let v6 = l.starts_with("[::]") || l.starts_with('*');
|
||||
port_ok && v6
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1746,6 +1746,11 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
|
||||
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
|
||||
// same reason router's mDNS/SSDP and every p2p port is exempt; each
|
||||
// carries its auth_rationale in the manifest.
|
||||
//
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
@@ -1771,7 +1776,7 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
28,
|
||||
30,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
@@ -1801,15 +1806,22 @@ app:
|
||||
}
|
||||
}
|
||||
open.sort();
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies) and
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||
// by anonymous payers). Both enforce their own account login, and an
|
||||
// operator can re-gate either from Settings → Access control.
|
||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||
// three own-login consoles brought onto the manifest platform:
|
||||
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
|
||||
// (tailnet login on the web console), adguardhome 3000 (AGH admin
|
||||
// accounts + first-run wizard). All enforce their own login, and an
|
||||
// operator can re-gate any of them from Settings → Access control.
|
||||
assert_eq!(
|
||||
open,
|
||||
vec![
|
||||
("adguardhome".to_string(), 3000u16),
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("gitea".to_string(), 3001u16)
|
||||
("gitea".to_string(), 3001u16),
|
||||
("nginx-proxy-manager".to_string(), 8081u16),
|
||||
("tailscale".to_string(), 8240u16),
|
||||
],
|
||||
"gate-open port set changed — every entry must be an app with its own login"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# HANDOFF — deploy companion 0.5.28 (vc48) to the live surfaces
|
||||
|
||||
**For: the agent on archi-dev-box.** Companion 0.5.28 shipped to `main`
|
||||
today (PR #149, merge `9f1a289d` — backup & restore #128, NIP-46 remote
|
||||
signer #139, companion-gated install pitch #61 residual, hub sub-pages).
|
||||
The dev box verified everything it can reach; three live surfaces remain,
|
||||
same shape as the 2026-07-23 deploy handoff
|
||||
([`HANDOFF-2026-07-23-companion-apk-deploy.md`](HANDOFF-2026-07-23-companion-apk-deploy.md)).
|
||||
|
||||
## Already done and verified (do not redo)
|
||||
|
||||
- `neode-ui/public/packages/archipelago-companion.apk` on `main` is
|
||||
**0.5.28 / versionCode 48**, clean build via `Android/ship-companion.sh`,
|
||||
**v1+v2+v3 signatures verified**, meta json refreshed beside it.
|
||||
- Gitea raw-on-main serves it byte-identical:
|
||||
`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`
|
||||
(2 bytes: 28,206,999… file size ≈ 28.2 MB).
|
||||
- The foundation server's **raw-proxy** path already serves 0.5.28 (verified
|
||||
via `https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json`).
|
||||
- Demo CI (`demo-images.yml`) fired on the push and redeploys the stack via
|
||||
the Portainer webhook — should flip on its own; confirm only.
|
||||
- Signing key unchanged (cert SHA-256 `d622e07e…ec2664d`), so phones update
|
||||
**in place** over any 0.5.27 install.
|
||||
|
||||
## 1. Foundation server static `/packages/` mirror — the real-node QR URL
|
||||
|
||||
`https://source.archipelago-foundation.org/packages/archipelago-companion.apk`
|
||||
is a **static dir** on the release server (openresty; still 0.5.27,
|
||||
last-modified 2026-08-17). This is the exact URL real nodes' companion QR
|
||||
downloads (`DEFAULT_DOWNLOAD_URL` in `CompanionIntroOverlay.vue`) — it must
|
||||
flip before the release is done.
|
||||
|
||||
```bash
|
||||
# Find the webroot once:
|
||||
grep -rl "packages" /etc/openresty /etc/nginx 2>/dev/null
|
||||
find / -name archipelago-companion.apk -not -path '/proc/*' 2>/dev/null
|
||||
|
||||
# Mirror the exact bytes from Gitea raw-on-main (no rebuild, no re-sign):
|
||||
cd <that webroot>
|
||||
curl -fsS -o archipelago-companion.apk http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk
|
||||
curl -fsS -o archipelago-companion.json http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json
|
||||
shasum -a 256 archipelago-companion.apk
|
||||
# MUST print: fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca
|
||||
```
|
||||
|
||||
## 2. Node web-bundle redeploys
|
||||
|
||||
Same as 2026-07-23: redeploy the web-ui bundle from current `main` to the
|
||||
active nodes — web root `/opt/archipelago/web-ui/` (NOT a `neode-ui/`
|
||||
subfolder), at minimum every node the user pairs against. The APK rides in
|
||||
the bundle's `packages/` dir, so this is also what makes each node's own
|
||||
served QR download 0.5.28.
|
||||
|
||||
## 3. Confirm the demo flipped
|
||||
|
||||
`curl -s http://146.59.87.168:2100/packages/archipelago-companion.json`
|
||||
should read 0.5.28/48 once CI's Portainer webhook redeploy lands; trigger a
|
||||
stack redeploy if it lags.
|
||||
|
||||
## Final verify (all three must show 0.5.28 / 48)
|
||||
|
||||
```bash
|
||||
aapt2 dump badging <downloaded apk> | head -1 # versionCode='48' versionName='0.5.28-debug'
|
||||
apksigner verify -v --min-sdk-version 21 <downloaded apk> | grep scheme # v1/v2/v3 true
|
||||
curl -s https://source.archipelago-foundation.org/packages/archipelago-companion.json
|
||||
curl -s http://146.59.87.168:2100/packages/archipelago-companion.json
|
||||
```
|
||||
|
||||
Then the user's on-device end-to-end: scan the node's companion QR →
|
||||
installs vc48 in place → hub → Backup & Restore / Remote Signer.
|
||||
Testing notes for the new features live in the closed tracker issues
|
||||
(#61/#128/#139) and `docs/companion-backup-restore.md` /
|
||||
`docs/companion-nip46-remote-signer.md` (the signer's e2e harness:
|
||||
`Android/tools/nip46-test-client.py`).
|
||||
@@ -0,0 +1,116 @@
|
||||
# HANDOFF — SSH over the FIPS mesh (node-side toggle), 2026-08-31
|
||||
|
||||
**For: the node OS agent.** From the companion agent, mid-0.5.28 testing. The
|
||||
user wants to SSH their node from Termux over the phone's FIPS mesh instead
|
||||
of keeping Tailscale around for it — the phone side is done and verified; the
|
||||
remaining work is all node-side, and it wants to be a **first-class settings
|
||||
toggle**, not a hand-edited firewall rule.
|
||||
|
||||
## What already works (do not rebuild this)
|
||||
|
||||
- The companion's embedded mesh is a **device-wide split tunnel**
|
||||
(`ArchyVpnService` routes `fd00::/8` for the whole phone, no per-app
|
||||
filter, `allowBypass`). Termux — or any app — reaches mesh addresses with
|
||||
zero setup while the tunnel is up, on-LAN and away (anchor path).
|
||||
- The hub's Nodes page now **displays and copies each FIPS node's `fips0`
|
||||
ULA** (committed on `companion/0.5.28`).
|
||||
- Verified live today: `ssh user@<node-ULA>` from Termux answers **RST** —
|
||||
the path works end-to-end; something on the node is doing the refusing.
|
||||
|
||||
## The diagnosis (from today's field test + code read)
|
||||
|
||||
1. **`fips0` is default-deny inbound.** The hardening baseline
|
||||
(`/etc/fips/fips.nft`, provisioned out-of-band) rejects un-allowlisted
|
||||
ports with RST — the exact symptom the web-UI drop-in's comment documents
|
||||
on :80 (`core/archipelago/src/fips/config.rs` ~L237). The daemon's own
|
||||
drop-ins (`/etc/fips/fips.d/80-web-ui.nft`: 80/8443/5679,
|
||||
`85-app-ports.nft`: app launch ports) **do not include 22**.
|
||||
2. **sshd IPv6 listening is unverified.** `fips0` is IPv6-only; a sshd pinned
|
||||
to `ListenAddress 0.0.0.0` RSTs on the ULA identically. The image installs
|
||||
and enables openssh-server (`image-recipe/archipelago-scripts/install-to-disk.sh`
|
||||
L177/L210) with default config (binds `::`), but a preflight in the toggle
|
||||
should confirm rather than assume.
|
||||
|
||||
**Interim manual unblock (what the user can do today, keep valid):**
|
||||
`/etc/fips/fips.d/90-ssh.nft` containing `ip6 saddr <phone-ULA> tcp dport 22
|
||||
accept`, then `sudo nft -f /etc/fips/fips.nft`. A daemon-owned toggle must
|
||||
**own that file name/lifecycle** so a hand-added rule and the feature don't
|
||||
fight over the same slot.
|
||||
|
||||
## The ask: a "SSH over mesh" toggle
|
||||
|
||||
The user's instinct (seconded here): **a setting in the FIPS/network area of
|
||||
the node UI**, default **off**. Sketch:
|
||||
|
||||
- **UI**: a small settings card in the pattern of
|
||||
`neode-ui/src/views/settings/` (see `TransportPrefsCard.vue` for a
|
||||
segmented-pref card + vitest). Toggle + a source-scope selector +
|
||||
preflight status rows.
|
||||
- **RPC**: `fips.ssh-over-mesh.get` / `fips.ssh-over-mesh.set` (dispatch arm
|
||||
in `core/archipelago/src/api/rpc/dispatcher.rs` alongside the existing
|
||||
`fips.*` arms at ~L544; handler in `api/rpc/fips.rs`). Persisted with the
|
||||
other fips daemon-config state.
|
||||
- **Enforcement**: mirror the existing drop-in lifecycle in
|
||||
`core/archipelago/src/fips/config.rs` (~L243–320): when the toggle is on,
|
||||
write `/etc/fips/fips.d/90-ssh.nft` on every daemon config install and on
|
||||
toggle change; when off, remove it. Reload stays
|
||||
`sudo nft -f /etc/fips/fips.nft`. Never touch `80-web-ui.nft` /
|
||||
`85-app-ports.nft`.
|
||||
- **Source scope** (the design decision worth an issue thread):
|
||||
- *Paired phones only* — restricts to the phone ULAs/npubs the node has
|
||||
actually paired with. Open question: does the node durably know which
|
||||
inbound peers are "its" phones? FIPS accepts inbound peers without prior
|
||||
registration, so this may need a small persisted "trusted peers" list
|
||||
(seeded when `fips.pair-info` is issued, or on first successful dial).
|
||||
Recommended default if the data can be made reliable.
|
||||
- *Custom source list* — raw ULA list, per-rule `ip6 saddr <ula> …`
|
||||
entries. Escape hatch; fine to ship alongside.
|
||||
- *Any mesh peer* — what the user literally asked for, but flag it
|
||||
honestly in the UI: with no registration requirement, this faces port 22
|
||||
at every peer that can route to the node over the mesh. If offered at
|
||||
all, gate it behind the same "I understand" confirmation pattern as
|
||||
other danger-zone settings.
|
||||
- **Preflights, surfaced in the card**: sshd enabled + listening on IPv6
|
||||
(`[::]:22` or `*:22` via `ss -tln`), and whether
|
||||
`PasswordAuthentication` is on — if it is, show a keys-only recommendation
|
||||
(the firewall restriction is the belt; this is the suspenders).
|
||||
|
||||
## Acceptance (on-device)
|
||||
|
||||
- [ ] Toggle on, phone on LAN: `ssh user@<node-ULA>` from Termux connects.
|
||||
- [ ] Phone away from LAN (anchor path): same result.
|
||||
- [ ] Toggle off: connection refused again; `90-ssh.nft` gone.
|
||||
- [ ] Daemon config install (upgrade/restart) preserves the on-state and
|
||||
the rule; nothing duplicated.
|
||||
- [ ] Non-default source scope actually restricts (try from a second mesh
|
||||
peer, or a wrong ULA).
|
||||
- [ ] Settings UI survives a page reload; RPC has a vitest like
|
||||
`TransportPrefsCard.test.ts`.
|
||||
|
||||
## Addendum (2026-08-31, same day): the npub IS the address
|
||||
|
||||
While wiring this up we confirmed the mesh ULA is a **pure function of the
|
||||
public key** — `fd ‖ sha256(x-only pubkey)[0..15]` (`fips/src/identity/node_addr.rs`
|
||||
`from_pubkey` → `identity/address.rs` `from_node_addr`,
|
||||
`FIPS_ADDRESS_PREFIX = 0xfd`). The daemon's DNS resolver (`fips/dial.rs`) just
|
||||
answers what anyone can compute. Consequences for the node side:
|
||||
|
||||
- Docs/UI can advertise `ssh <user>@npub1…`-style addressing: Termux's
|
||||
`Android/tools/fipssh` (shipped with the companion work) derives the ULA
|
||||
from the npub with zero infrastructure, verified byte-identical against
|
||||
the fips crate (`archy-fips-core` test
|
||||
`npub_derives_the_same_mesh_ula_as_the_fips_identity`).
|
||||
- If the settings toggle from this handover ever grows a "copy command"
|
||||
affordance, `fipssh <user>@<npub>` is the natural shape (npub, not ULA —
|
||||
it is the durable identity; the ULA follows from it).
|
||||
- No node-side DNS surface is required for the SSH case; the resolver stays
|
||||
what it is today (the node's own peer dials).
|
||||
|
||||
## Working rules
|
||||
|
||||
Same as the queue handoffs: small commits, tracker issue for this feature
|
||||
(`ssh-over-mesh`), and the companion agent is downstream-only here — no
|
||||
companion changes are required (the phone already routes and displays the
|
||||
ULA). Optional nicety later, NOT part of this issue: the companion's FIPS
|
||||
hub page could one day surface the toggle state — only worth it if the
|
||||
`fips.ssh-over-mesh.get` RPC is trivial to add to the existing status call.
|
||||
@@ -0,0 +1,154 @@
|
||||
# SESSION — companion 0.5.28: shipped, published, playbook (2026-08-31)
|
||||
|
||||
**For: the companion agent (next session) + anyone shipping a companion
|
||||
release.** Session that closed the 2026-08-30 companion-agent queue (#61
|
||||
residual, #128, #139) and shipped 0.5.28 end-to-end.
|
||||
|
||||
## Release state at session end — ALL LIVE
|
||||
|
||||
Companion **0.5.28 / versionCode 48**, main @ PR #149 (`9f1a289d`), deploy
|
||||
handoff merged as PR #150 (`91374392`). Every public surface verified
|
||||
byte-identical (`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`):
|
||||
|
||||
| Surface | URL | State |
|
||||
|---|---|---|
|
||||
| Gitea raw-on-main | `http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk` | ✅ 0.5.28, v1+v2+v3 verified on download |
|
||||
| Foundation static `/packages/` (real-node QR URL) | `https://source.archipelago-foundation.org/packages/archipelago-companion.apk` | ✅ 0.5.28 |
|
||||
| Foundation Gitea-raw proxy | `…/lfg2025/archy/raw/branch/main/…` | ✅ 0.5.28 (6h cache — may lag after pushes) |
|
||||
| Demo `:2100` | `http://146.59.87.168:2100/packages/archipelago-companion.apk` | ✅ 0.5.28 (auto: CI + Portainer webhook) |
|
||||
|
||||
Only remaining live-surface step: **node web-bundle redeploys** so each
|
||||
node's own served copy is 0.5.28 — archi-dev-box's standard step, written up
|
||||
in `docs/HANDOFF-2026-08-31-companion-0.5.28-deploy.md` (its §1/§3 were
|
||||
already done by the time of this doc — only §2 outstanding).
|
||||
|
||||
Tracker: #128 and #139 closed with what-shipped comments; #61 (already
|
||||
closed) got a residual-fix follow-up. Signing cert unchanged (`d622e07e…`),
|
||||
so phones update in place.
|
||||
|
||||
## What shipped in 0.5.28 (map)
|
||||
|
||||
- **#61 residual (web)**: `isCompanionApp()` gates on `CompanionBanner.vue`
|
||||
render, `openCompanionIntro()` (useCompanionIntro.ts), and the overlay's
|
||||
manual-open watcher; overlay moved to the canonical helper. Vitest suite
|
||||
green (1013 tests).
|
||||
- **#128 Backup & Restore**: `Android/rust/archy-fips-core/src/backup.rs`
|
||||
(ADR-005 envelope, node-compatible), `BackupManager.kt`, hub sub-page
|
||||
`ui/components/BackupSection.kt`. Doc: `companion-backup-restore.md`.
|
||||
- **#139 Remote Signer**: `src/nostr.rs` (NIP-44 v2 + NIP-04 + BIP-340,
|
||||
official vectors), `nostr/BunkerManager.kt` + `NostrSignerPreferences.kt`,
|
||||
hub sub-page `ui/components/SignerSection.kt`, `nostrconnect://` deep link
|
||||
via `SignerLaunch`. Harness: `Android/tools/nip46-test-client.py`.
|
||||
Doc: `companion-nip46-remote-signer.md`.
|
||||
- **Hub modal redesign** (field feedback): both features are sub-pages like
|
||||
Nodes/FIPS; panel height cap 70%; scanner hosted by NESMenu outside the
|
||||
panel; back-arrow → hub.
|
||||
- **Extras**: node mesh ULA shown/copyable in the Nodes list (`MenuItem`
|
||||
subtitle); `Android/tools/fipssh` (npub→ULA is pure: `fd ‖ sha256(pubkey)[0..15]`,
|
||||
pinned by `npub_derives_the_same_mesh_ula_as_the_fips_identity` test).
|
||||
- **Node-side handoffs written**: `HANDOFF-2026-08-31-ssh-over-mesh.md`
|
||||
(SSH-over-mesh toggle) and the 0.5.28 deploy handoff.
|
||||
|
||||
## The deployment playbook (learned the hard way this session)
|
||||
|
||||
### Networking — everything goes through the Tor SOCKS proxy
|
||||
|
||||
Direct connections to `146.59.87.168` fail from this box ("Bad file
|
||||
descriptor"); git works because `~/.gitconfig` sets
|
||||
`proxy = socks5h://127.0.0.1:9050`. **For curl/Gitea API you must pass it
|
||||
explicitly:**
|
||||
|
||||
```bash
|
||||
curl -s --socks5-hostname 127.0.0.1:9050 ... # works
|
||||
curl -s ... # HTTP 000, "unreachable"
|
||||
```
|
||||
|
||||
This is why earlier sessions concluded "Gitea API unreachable" — wrong; it
|
||||
just needs the proxy flag.
|
||||
|
||||
### Gitea API + auth
|
||||
|
||||
- Base: `http://146.59.87.168:3000/api/v1` (v1.27.1), via the proxy.
|
||||
- The keychain git credential (`security find-internet-password -s
|
||||
146.59.87.168`, acct `v4v`) is a **`write:repository`-only token** — fine
|
||||
for git, CANNOT read/write issues.
|
||||
- Issue ops need `write:issue`. This session the user pasted a broad token
|
||||
(activitypub+misc+notification+organization+package+issue+repository) —
|
||||
**revocation still pending** (it's in chat scrollback). Ask the user for a
|
||||
scoped `write:issue` token next time.
|
||||
|
||||
### main is PROTECTED — ship via -ship branch + PR + API merge
|
||||
|
||||
`git push origin main` is rejected by pre-receive. The working sequence:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout main && git reset --hard origin/main # local main is STALE (see below)
|
||||
git merge --no-ff companion/<ver> -m "Companion <ver> — …"
|
||||
./Android/ship-companion.sh # builds, signs v1+v2+v3, stages APK+meta, commits
|
||||
# its `git push` FAILS on protected main — expected. Push the branch instead:
|
||||
git push origin main:companion/<ver>-ship
|
||||
# then create + merge the PR via API:
|
||||
curl ... POST repos/lfg2025/archy/pulls {"head":"companion/<ver>-ship","base":"main",...}
|
||||
curl ... POST repos/lfg2025/archy/pulls/<n>/merge -d '{"Do":"merge"}'
|
||||
```
|
||||
|
||||
(Refinement for next time: run `ship-companion.sh` ON the `-ship` branch
|
||||
from the start — it pushes the current branch, which for a `-ship` branch
|
||||
succeeds directly.)
|
||||
|
||||
- **Local `main` is the pre-open-source-import lineage** (1115 stale
|
||||
commits, unrelated history). Always `reset --hard origin/main` before
|
||||
using it; never merge into it without the reset.
|
||||
- A **stale tag ref** (`v1.7.115-alpha`) can make `git fetch` fail
|
||||
("did not send all necessary objects") — `rm .git/refs/tags/v1.7.115-alpha`.
|
||||
- Last release's `-ship` branch for reference: `origin/companion/0.5.27-ship`.
|
||||
|
||||
### Build + verify (per release)
|
||||
|
||||
- Version lives in `Android/app/build.gradle.kts` (`versionCode` must
|
||||
strictly increase; meta json is auto-generated by the publish script from
|
||||
it). 0.5.28 → next is **0.5.29/vc49**.
|
||||
- APK package is `com.archipelago.app.debug` (the served artifact IS the
|
||||
debug build, committed repo keystore, cert SHA-256 `d622e07e…ec2664d`).
|
||||
Local `Android/app/debug.keystore` is untracked but produces that cert —
|
||||
verify per release: `apksigner verify --print-certs` on old vs new.
|
||||
- Build: `cd Android && JAVA_HOME=/opt/homebrew/opt/openjdk@17
|
||||
ANDROID_HOME=$HOME/Library/Android/sdk ./gradlew :app:assembleDebug`
|
||||
(builds the Rust via cargo-ndk, NDK under `~/Library/Android/sdk/ndk/`).
|
||||
Test build for the user: copy to `~/Desktop/archipelago-companion-<ver>.apk`.
|
||||
- Rust: `cd Android/rust/archy-fips-core && cargo test --lib` (24 tests at
|
||||
session end) + clippy. neode-ui: `npm ci` first (node_modules not kept),
|
||||
`npx vitest run`, `npm run type-check`.
|
||||
- Post-ship verify block: aapt2 badging, shasum vs Gitea raw, apksigner
|
||||
v1/v2/v3, the three public URLs' meta json (table above), foundation
|
||||
raw-proxy may serve up to 6h stale (cache-control: max-age=21600).
|
||||
|
||||
### Infrastructure facts
|
||||
|
||||
- `source.archipelago-foundation.org` = openresty on vps2 with **two
|
||||
surfaces**: static `/packages/` (manual mirror; the real-node QR URL) and
|
||||
a Gitea-raw proxy (6h cache, auto). Demo `:2100` redeploys automatically:
|
||||
`.gitea/workflows/demo-images.yml` fires on `main` pushes touching
|
||||
`neode-ui/**`, then calls the Portainer webhook.
|
||||
- **No SSH to vps2 from this box**: `archy_146_release` key declined for
|
||||
root/archipelago/dorian/lfg2025/deploy/git. Server-side work needs the
|
||||
archi-dev-box agent or the user.
|
||||
|
||||
## Open items for next session
|
||||
|
||||
1. **Confirm node web-bundle redeploys** happened (archi-dev-box; deploy
|
||||
handoff §2) — a paired node's own `/packages/` should serve vc48.
|
||||
2. **Token revocation** (user) + request a `write:issue`-scoped one.
|
||||
3. **Node-side roadmap** fed by this release: SSH-over-mesh toggle
|
||||
(`HANDOFF-2026-08-31-ssh-over-mesh.md`), node NIP-46 client (login flow B),
|
||||
node-side storage for companion backup envelopes.
|
||||
4. **On-device follow-ups**: the user's full 0.5.28 pass — signer e2e via
|
||||
the harness (`/tmp/nip46env/bin/python Android/tools/nip46-test-client.py`),
|
||||
backup round-trip on a wipe, and the zxing-cpp decision trigger
|
||||
(move-to-the-code; sketch is verified online:
|
||||
`io.github.zxing-cpp:android:3.1.1`, still NOT-actioned by design).
|
||||
5. Untracked on this box, deliberately left: `Android/app/debug.keystore`,
|
||||
`docs/1.8-alpha-improvements-tracker.md`,
|
||||
`docs/SESSION-1.8.0-OTA-PROGRESS.md`, `image-recipe/branding/source-logos/`
|
||||
(other workstreams' files).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Companion backup & restore — the phone side of a border crossing (#128)
|
||||
|
||||
**Status:** shipped in companion 0.5.28 (vc48). Issue: #128 ("Graphene phone
|
||||
backup/restore — part of the companion app or passport prime combo").
|
||||
|
||||
## The problem
|
||||
|
||||
The companion holds real secrets: node addresses and login passwords, the
|
||||
phone's FIPS mesh identity (which nodes peer with), and — since 0.5.28 — the
|
||||
remote-signer key. Losing the phone, or wiping it to cross a border, loses all
|
||||
of it. On GrapheneOS there is no cloud backup and there should be none here
|
||||
either: the export is a plain file the user saves wherever they choose (USB
|
||||
drive, computer, a folder synced their way), sealed with a passphrase.
|
||||
|
||||
## The envelope — the node's, not a second format
|
||||
|
||||
Backups use the node's ADR-005 encrypted-backup envelope
|
||||
(`core/archipelago/src/backup/identity.rs`), byte-for-byte:
|
||||
|
||||
- Argon2id key derivation (RustCrypto `argon2`, default params — same as the
|
||||
node's `Argon2::default()`), passphrase in, 16-byte random salt.
|
||||
- ChaCha20-Poly1305 AEAD with a 12-byte random nonce.
|
||||
- Envelope JSON:
|
||||
`{"version": 1, "kind": "companion", "encrypted": true, "blob": "<base64(salt‖nonce‖ct)>", "timestamp": "<rfc3339>"}`
|
||||
- The native code (`Android/rust/archy-fips-core/src/backup.rs`) is the same
|
||||
crate family as the node's backup code; `decrypt` ignores unknown envelope
|
||||
fields, so a **node** identity backup (which carries `did`/`pubkey`/`kid`)
|
||||
also decrypts here — one envelope, two producers.
|
||||
|
||||
The encrypted payload is the companion's own JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "archipelago-companion",
|
||||
"payloadVersion": 1,
|
||||
"appVersion": "0.5.28",
|
||||
"createdAt": 1725100000,
|
||||
"servers": ["<serialized ServerEntry>", …],
|
||||
"active": "<serialized ServerEntry or null>",
|
||||
"fips": {"secret","npub","address","peers","partyPeers","partyName","partyListen"},
|
||||
"signer": {"secret": "<hex>"},
|
||||
"flags": {"introSeen": true}
|
||||
}
|
||||
```
|
||||
|
||||
## Where the code lives
|
||||
|
||||
- **Crypto:** `Android/rust/archy-fips-core/src/backup.rs` (+ JNI
|
||||
`NativeCore.backupEncrypt/Decrypt`). Host `cargo test` covers round-trip,
|
||||
wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce
|
||||
freshness.
|
||||
- **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`).
|
||||
- **UI:** a hub sub-page (`ui/components/BackupSection.kt`, opened from the
|
||||
three-finger hub menu like Nodes/FIPS) — SAF file picker
|
||||
(`CreateDocument` for export, `OpenDocument` for import), passphrase
|
||||
fields, verified-backup preview, result summary. The suggested export
|
||||
name is `archy-companion-backup-YYYYMMDD-HHmmss.json`.
|
||||
|
||||
## Restore semantics — never silently destructive
|
||||
|
||||
| What | On restore |
|
||||
|---|---|
|
||||
| Servers | Upsert (`ServerPreferences.upsertServer`): same npub merges (even when every address changed), new ones append |
|
||||
| Active server | Set only when this phone has none (the fresh-wipe case) |
|
||||
| FIPS identity | Restored only when this phone has none; node peers UNION by npub (`FipsPreferences.mergePeersJson`); party peers merge by npub |
|
||||
| Signer key | Restored only when this phone has none |
|
||||
| introSeen flag | Restored (no re-onboarding after a restore) |
|
||||
|
||||
The identity rules exist because a phone that already paired has a live mesh
|
||||
identity nodes peer with; swapping it in from a backup would strand the
|
||||
current pairing.
|
||||
|
||||
## Test checklist (on-device)
|
||||
|
||||
- [ ] Export → file saved, `version: 1`, `kind: companion`, base64 blob ≥ 44 chars.
|
||||
- [ ] Wrong passphrase on import → "wrong passphrase" error, no state change.
|
||||
- [ ] Correct passphrase → preview shows the right server count; restore on a
|
||||
second install (or after clearing app data) reconnects to the node
|
||||
without re-pairing, mesh included.
|
||||
- [ ] Re-scan the node's QR after restore → no duplicate entry.
|
||||
- [ ] The old phone's password for a node restores (login works on the new phone).
|
||||
|
||||
## Roadmap notes (node-side, tracked separately)
|
||||
|
||||
Node-side storage/quota/scheduling for companion backups ("passport prime
|
||||
combo") is roadmap territory — this issue's scope was the phone side. The
|
||||
envelope is ready to be a drop-in for the node's existing backup RPCs when
|
||||
that lands.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Companion NIP-46 remote signer — the phone side of Nostr Bunker (#139)
|
||||
|
||||
**Status:** shipped in companion 0.5.28 (vc48). Issue: #139 ("Remote signer
|
||||
with companion app?"). Background research:
|
||||
[`nostr-signer-login-research.md`](nostr-signer-login-research.md) — flow B of
|
||||
that document is exactly the flow this implements, with the companion playing
|
||||
the role the research assigned to Amber.
|
||||
|
||||
## What shipped: the phone IS the bunker (remote signer)
|
||||
|
||||
The companion holds a nostr key (generate or import an `nsec`) and speaks
|
||||
NIP-46 as the **remote signer**:
|
||||
|
||||
1. A NIP-46 client — the node's login page, per the research doc's flow B,
|
||||
or any `nostrconnect://`-emitting app — shows its pairing QR.
|
||||
2. The phone scans it (hub → **Remote Signer** → *Scan pairing QR*), or any
|
||||
QR-scanner app hands the `nostrconnect://` URI over as a deep link
|
||||
(registered in the manifest).
|
||||
3. The phone connects to the client's relay(s), subscribes to kind-24133
|
||||
events p-tagged to its own key, and sends the `connect` request carrying
|
||||
the secret — the same handshake direction rust-nostr's reference bunker
|
||||
uses (`NostrConnectRemoteSigner::send_connect_ack`), which is what the
|
||||
node's eventual nostr-connect client will wait for.
|
||||
4. Requests arrive NIP-44-encrypted. Handled methods:
|
||||
- `connect` → "ack" (validates our pubkey + the pairing secret)
|
||||
- `get_public_key` → our pubkey
|
||||
- `describe` → method list
|
||||
- `ping` → "pong"
|
||||
- **`sign_event` → an approve/deny card — kind label, content, tags,
|
||||
time. Nothing signs without a thumb on Approve.** Deny replies
|
||||
`"denied"`; a second request while one is pending replies `"busy"`
|
||||
instead of replacing the visible card.
|
||||
- anything else → `"not authorized"` (nip04/nip44 encrypt/decrypt are
|
||||
deliberately NOT granted in v1).
|
||||
5. Responses go back over the same encrypted kind-24133 channel.
|
||||
|
||||
The session lives while the app does (the login handshake takes seconds);
|
||||
remembered-session auto-reconnect is the research doc's deferred flow C, and
|
||||
stays deferred. NIP-04 is accepted on receive as a fallback (deprecated but
|
||||
still spoken by real clients); all sending is NIP-44 v2.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
- **Crypto:** `Android/rust/archy-fips-core/src/nostr.rs` — nsec/npub bech32
|
||||
keys, BIP-340 schnorr event signing (NIP-01 id serialization), NIP-44 v2
|
||||
payloads, NIP-04 fallback, `nostrconnect://` parsing. Host `cargo test`
|
||||
runs the official NIP-44 vectors (conversation/message keys, padded
|
||||
lengths, byte-exact encrypt vectors), the official BIP-340 sign vectors,
|
||||
and round-trip/tamper/failure cases.
|
||||
- **JNI:** `com.archipelago.app.NativeCore` (same .so as the FIPS mesh).
|
||||
- **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client,
|
||||
JSON-RPC dispatch, approve/deny state.
|
||||
- **UI:** a hub sub-page (`ui/components/SignerSection.kt`, opened from the
|
||||
three-finger hub menu like Nodes/FIPS) — key setup, npub/nsec display,
|
||||
pairing scan, session status, the approve/deny card. The full-screen
|
||||
pairing scanner (`QrGlassModal`) is hosted by NESMenu so it isn't clipped
|
||||
to the panel's bounds. The `nostrconnect://` deep link routes to the
|
||||
session and pops the hub open on the signer sub-page (`SignerLaunch`).
|
||||
|
||||
## Security notes (conscious deviations, reviewed)
|
||||
|
||||
- Incoming events are **not** signature-verified before decryption — the
|
||||
same choice rust-nostr's reference bunker makes. The NIP-44 MAC is the
|
||||
actual gate: forging content that decrypts with a valid MAC requires one
|
||||
of the two conversation secrets. A future hardening pass may add event
|
||||
verification first.
|
||||
- The signer secret lives in app-private DataStore (same storage model as
|
||||
the FIPS secret and node login passwords). It can additionally be sealed
|
||||
inside an encrypted backup (see
|
||||
[`companion-backup-restore.md`](companion-backup-restore.md)).
|
||||
- `sign_event` approval is per-request and per-screen; there is no
|
||||
"remember this client" auto-approve in v1.
|
||||
|
||||
## End-to-end test harness (the node side doesn't exist yet)
|
||||
|
||||
`Android/tools/nip46-test-client.py` plays the node's role: generates the
|
||||
pairing QR in your terminal, runs the full handshake, requests
|
||||
`get_public_key` + `sign_event`, and verifies the returned signature with an
|
||||
independent pure-Python BIP-340 implementation (no code shared with the
|
||||
phone's Rust core; both are pinned to the same official test vectors).
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/nip46env
|
||||
/tmp/nip46env/bin/pip install websockets qrcode
|
||||
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py # --relay to override
|
||||
```
|
||||
|
||||
Then on the phone: hub → Remote Signer → Generate key (once) → Scan pairing
|
||||
QR → point at the terminal QR → Approve the incoming request. The harness
|
||||
prints `END-TO-END PASS` when the phone-signed event verifies.
|
||||
|
||||
## Test checklist (on-device)
|
||||
|
||||
- [ ] Generate key → npub shows, copy works; import nsec → same npub.
|
||||
- [ ] Harness handshake: pair → ack → `get_public_key` returns the phone's npub.
|
||||
- [ ] `sign_event` request shows a legible card (kind label, content, tags);
|
||||
Approve → harness verifies the schnorr signature; Deny → harness sees
|
||||
`"denied"`.
|
||||
- [ ] Deep link: open a `nostrconnect://…` URI from a QR app → SignerScreen
|
||||
with the pairing already starting.
|
||||
- [ ] Wrong/foreign QR → clear error, no state change.
|
||||
|
||||
## Roadmap (node-side, tracked separately)
|
||||
|
||||
The node-side bunker hosting/login flow (research doc flows A+B, the
|
||||
`auth.login.nostr` slot, relay topology on the node's own strfry) is roadmap
|
||||
territory via the `companion-agent`-labeled tracker issues; when it ships,
|
||||
the phone side here already speaks its language.
|
||||
@@ -88,29 +88,29 @@ proprietary and Play-Services-backed.
|
||||
|
||||
## Integration sketch
|
||||
|
||||
> ⚠️ Coordinates and API surface below are from memory and were **not**
|
||||
> verified against Maven Central — the machine this was written on had no
|
||||
> network. Confirm the current artifact version and wrapper API on the first
|
||||
> online Gradle sync before trusting the snippet.
|
||||
> Verified 2026-08-31 against Maven Central and the wrapper source
|
||||
> (`wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt` at
|
||||
> `io.github.zxing-cpp:android:3.1.1`, the current release). Coordinates and
|
||||
> API below are what the published artifact actually ships.
|
||||
|
||||
`Android/app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
// Replaces com.google.zxing:core for the live-camera path.
|
||||
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
|
||||
implementation("io.github.zxing-cpp:android:3.1.1")
|
||||
```
|
||||
|
||||
`QrCodeAnalyzer` collapses to roughly:
|
||||
|
||||
```kotlin
|
||||
private val reader = BarcodeReader().apply {
|
||||
private val reader = BarcodeReader(
|
||||
options = BarcodeReader.Options(
|
||||
formats = setOf(BarcodeFormat.QR_CODE),
|
||||
formats = setOf(BarcodeReader.Format.QR_CODE),
|
||||
tryHarder = true,
|
||||
tryRotate = true,
|
||||
tryInvert = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
@@ -121,6 +121,15 @@ override fun analyze(image: ImageProxy) {
|
||||
}
|
||||
```
|
||||
|
||||
API notes from the published wrapper: `BarcodeReader.read(ImageProxy)` takes
|
||||
the CameraX `YUV_420_888` frame directly (it reads the Y plane + cropRect +
|
||||
rotation itself — the manual crop/copy machinery really can go); options are
|
||||
one constructor-argument data class; `Format.QR_CODE` is nested inside
|
||||
`BarcodeReader` (not a top-level `BarcodeFormat`); results carry `text`,
|
||||
`contentType`, `position` — and `lastReadTime` gives the per-call decode time
|
||||
in ms, useful to measure the claimed 5–10× while evaluating. Keep
|
||||
`com.google.zxing:core` for the still-image path regardless (below).
|
||||
|
||||
Keep `com.google.zxing:core` for now regardless: the still-image path
|
||||
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
||||
`prewarmQrScanner` both use it, and neither is on the hot path.
|
||||
|
||||
@@ -573,7 +573,7 @@ RUN mkdir -p /etc/polkit-1/rules.d && \
|
||||
# already-deployed nodes over OTA (idempotent no-op here once applied).
|
||||
RUN set -eu; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends kdump-tools kexec-tools rasdaemon; \
|
||||
apt-get install -y --no-install-recommends kdump-tools kexec-tools makedumpfile rasdaemon; \
|
||||
apt-get clean; rm -rf /var/lib/apt/lists/*; \
|
||||
CONF=/etc/default/kdump-tools; \
|
||||
sed -i 's|^#\?USE_KDUMP=.*|USE_KDUMP="1"|' "$CONF"; \
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.5-alpha",
|
||||
"version": "1.8.8-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.5-alpha",
|
||||
"version": "1.8.8-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.5-alpha",
|
||||
"version": "1.8.8-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
+391
-355
@@ -11,16 +11,47 @@
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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",
|
||||
"id": "adguardhome",
|
||||
"title": "AdGuard Home",
|
||||
"version": "v0.107.55",
|
||||
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
|
||||
"icon": "",
|
||||
"author": "AdGuard",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
|
||||
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
@@ -35,76 +66,16 @@
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"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",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.3",
|
||||
"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": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "botfights",
|
||||
@@ -132,127 +103,46 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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": "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="
|
||||
]
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "2.4.3",
|
||||
"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": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.10.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/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"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": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
@@ -299,54 +189,87 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
"8083:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
"/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": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"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": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
"3001:3000",
|
||||
"2222:22"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
"/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": "immich",
|
||||
"title": "Immich",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
|
||||
"icon": "/assets/img/app-icons/grafana.png",
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "homeassistant",
|
||||
@@ -370,11 +293,209 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "2.7.4",
|
||||
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "indeedhub",
|
||||
"title": "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.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8096:8096"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/jellyfin/config:/config",
|
||||
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration \u2014 the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay",
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.10.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/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
|
||||
"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": "ollama",
|
||||
"title": "Ollama",
|
||||
"version": "0.5.4",
|
||||
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama",
|
||||
"category": "community",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
@@ -382,26 +503,44 @@
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3000:3000"
|
||||
"9000:9000"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||
"/var/lib/archipelago/portainer:/data",
|
||||
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": [
|
||||
"GF_PATHS_DATA=/var/lib/grafana",
|
||||
"GF_USERS_ALLOW_SIGN_UP=false"
|
||||
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"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",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8888:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -433,51 +572,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"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": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
@@ -507,82 +601,24 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism",
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"2342:2342"
|
||||
"8082:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||
],
|
||||
"env": [
|
||||
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
|
||||
"PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
"title": "Nextcloud",
|
||||
"version": "29",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"8085:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
|
||||
"repoUrl": "https://github.com/getAlby/hub"
|
||||
},
|
||||
{
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
|
||||
"repoUrl": "https://github.com/ACINQ/phoenixd"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
"version": "0.1.0-preview",
|
||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"author": "Cuprate contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
|
||||
"repoUrl": "https://github.com/Cuprate/cuprate"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.5.27",
|
||||
"versionCode": 47
|
||||
"versionName": "0.5.28",
|
||||
"versionCode": 48
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@@ -205,10 +206,12 @@ const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
||||
// Running inside the companion app's own WebView (it injects the JS bridge —
|
||||
// detected with the canonical helper, not a raw window check, so the gate
|
||||
// is identical everywhere the question is asked).
|
||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||
// it. Server management for connected companions lives in the NESMenu instead.
|
||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
||||
const IN_COMPANION_APP = isCompanionApp()
|
||||
|
||||
onMounted(() => {
|
||||
if (IN_COMPANION_APP) return
|
||||
@@ -247,9 +250,13 @@ function maybeShow() {
|
||||
}
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
|
||||
// and this watcher refuses to open there too, so no caller can ever pop the
|
||||
// install pitch inside the app it installs (#61).
|
||||
watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (IN_COMPANION_APP) return
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { companionIntroRequested, openCompanionIntro } from '../useCompanionIntro'
|
||||
|
||||
// #61: the manual intro trigger (App Store banner etc.) must be a no-op inside
|
||||
// the companion app's WebView — the "install the companion" pitch is nonsense
|
||||
// where the user is already running it. The auto-popup was already gated
|
||||
// (CompanionIntroOverlay.onMounted); openCompanionIntro is the second, manual
|
||||
// path and the banner render (CompanionBanner) the third.
|
||||
|
||||
type TestWindow = Window & { ArchipelagoNative?: unknown }
|
||||
const w = window as TestWindow
|
||||
|
||||
beforeEach(() => {
|
||||
companionIntroRequested.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete w.ArchipelagoNative
|
||||
})
|
||||
|
||||
describe('openCompanionIntro', () => {
|
||||
it('raises the manual intro request in a plain browser/PWA', () => {
|
||||
expect(companionIntroRequested.value).toBe(false)
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op inside the companion app (bridge with openInApp)', () => {
|
||||
w.ArchipelagoNative = { openInApp: () => {}, openExternal: () => {} }
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(false)
|
||||
})
|
||||
|
||||
it('still fires when the bridge exists but is not the companion shell', () => {
|
||||
// Partial bridge (no openInApp) is not the companion app — a future
|
||||
// embedder must still see the pitch.
|
||||
w.ArchipelagoNative = { openExternal: () => {} }
|
||||
openCompanionIntro()
|
||||
expect(companionIntroRequested.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
|
||||
/**
|
||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||
* bypasses the once-per-browser auto-show gate.
|
||||
*
|
||||
* Inside the companion app's own WebView the whole pitch is nonsense — the
|
||||
* user is already running it — so the trigger is a no-op there (#61: the
|
||||
* auto-popup was gated, this manual path and CompanionBanner weren't).
|
||||
*/
|
||||
export const companionIntroRequested = ref(false)
|
||||
|
||||
export function openCompanionIntro(): void {
|
||||
if (isCompanionApp()) return
|
||||
companionIntroRequested.value = true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
|
||||
|
||||
// The signed catalog's embedded manifests decide which ports the app gate
|
||||
// fronts (TLS on the same port) — prime the same shape the live catalog
|
||||
// carries for the apps these tests launch.
|
||||
const SIGNED = {
|
||||
apps: {
|
||||
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
|
||||
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }] } } },
|
||||
'nginx-proxy-manager': { version: 'latest' }, // legacy: no manifest → http
|
||||
},
|
||||
}
|
||||
|
||||
// vi.hoisted runs before vi.mock hoisting
|
||||
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
|
||||
@@ -23,6 +35,7 @@ describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
__setSignedCatalogForTests(SIGNED as never)
|
||||
// Default to HTTP to avoid proxy rewriting
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' },
|
||||
@@ -256,6 +269,46 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// An HTTPS connection must never hand the remote browser (or the phone
|
||||
// webview) a cleartext app URL: same-host app ports are gate-owned and
|
||||
// serve TLS on the same port. Plain-http pages keep http exactly as before
|
||||
// — pinned by every test above this one.
|
||||
it('upgrades same-host app URLs to https on an https page', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.0.2.10:8082', title: 'Vaultwarden' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://192.0.2.10:8082',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('never upgrades a different host on an https page', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.100:8082', title: 'Vaultwarden' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.100:8082',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Gitea path URL in new tab', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { portIsGateFronted } from '@/views/discover/curatedApps'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
@@ -60,9 +61,6 @@ const NEW_TAB_APP_IDS = new Set([
|
||||
'netbird',
|
||||
])
|
||||
|
||||
// Apps served over HTTPS (self-signed) rather than plain HTTP.
|
||||
const HTTPS_APP_IDS = new Set(['netbird'])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@@ -147,33 +145,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
const APP_ID_TO_PORT: Record<string, string> = {
|
||||
'btcpay-server': '23000',
|
||||
grafana: '3000',
|
||||
photoprism: '2342',
|
||||
homeassistant: '8123',
|
||||
vaultwarden: '8082',
|
||||
nextcloud: '8085',
|
||||
portainer: '9000',
|
||||
tailscale: '8240',
|
||||
'nginx-proxy-manager': '8081',
|
||||
'uptime-kuma': '3002',
|
||||
gitea: '3001',
|
||||
// Without this, directAppUrl('netbird') returns null and netbird falls
|
||||
// through to the iframe (and never gets its https URL) — issue #15.
|
||||
netbird: '8087',
|
||||
}
|
||||
|
||||
function directAppUrl(appId: string): string | null {
|
||||
const port = APP_ID_TO_PORT[appId]
|
||||
if (!port || typeof window === 'undefined') return null
|
||||
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
|
||||
return `${scheme}://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
function getApprovedOrigins(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
||||
@@ -285,18 +257,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
|
||||
// its OIDC dashboard) must be launched over https — a stale http URL hits
|
||||
// the TLS port and 400s. Upgrade the scheme defensively in every path.
|
||||
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
if (u.protocol === 'http:') {
|
||||
u.protocol = 'https:'
|
||||
launchUrl = u.href
|
||||
}
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
// Scheme discipline for everything launched on this host. Ports fronted
|
||||
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
||||
// port — on an HTTPS connection those must open over https. Ports that
|
||||
// are NOT gate-fronted (legacy curated installs like Nginx Proxy Manager,
|
||||
// Tailscale; `auth: none` publishes) are plain HTTP and https would fail
|
||||
// to connect outright, so they keep http. External hosts keep their own
|
||||
// scheme.
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
|
||||
const httpsPage = window.location.protocol === 'https:'
|
||||
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
|
||||
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
|
||||
// Pure prefix swap — never re-serialize the URL (URL.href would add
|
||||
// a trailing slash and change the string the caller handed over).
|
||||
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
|
||||
}
|
||||
} catch { /* leave as-is */ }
|
||||
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
if (resolvedId) recordAppLaunch(resolvedId)
|
||||
|
||||
@@ -184,6 +184,7 @@ import {
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
import { fetchAppCatalog } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -238,10 +239,17 @@ watch(() => route.query.category, (category) => {
|
||||
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
|
||||
// TTL) behind a shared key so Discover.vue's identical loader picks up the
|
||||
// same cache entry without its own conversion (plan 02-04). Non-sensitive
|
||||
// and small, so it persists across reloads.
|
||||
// and small, so it persists across reloads. Dynamic-catalog-first: the
|
||||
// daemon-verified signed catalog is what makes a newly published app appear
|
||||
// without a dashboard release — the static list below is only the offline
|
||||
// fallback (same fetcher contract as Discover.vue for this shared key).
|
||||
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||
key: 'app-catalog',
|
||||
fetcher: async () => getCuratedAppList(),
|
||||
fetcher: async () => {
|
||||
const catalog = await fetchAppCatalog()
|
||||
if (catalog && catalog.apps.length) return catalog.apps
|
||||
return getCuratedAppList()
|
||||
},
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
})
|
||||
@@ -507,8 +515,10 @@ async function installApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
// Stay on the store page: the tile itself shows install progress via the
|
||||
// global watcher, and a forced jump to My Apps yanked the user out of the
|
||||
// page they were deliberately browsing.
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
@@ -532,8 +542,8 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
// Stay on the store page (see installApp).
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
|
||||
|
||||
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
|
||||
// that decides TLS eligibility). Kept minimal — only what the scheme logic
|
||||
// consults.
|
||||
const SIGNED = {
|
||||
apps: {
|
||||
vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } },
|
||||
gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }, { host: 2222, auth: 'none' }] } } },
|
||||
'btcpay-server': { version: '2.4.3', manifest: { app: { ports: [{ host: 23000, auth: 'open' }] } } },
|
||||
mempool: { version: '3.3.1', manifest: { app: { ports: [{ host: 4080, auth: 'gated' }] } } },
|
||||
filebrowser: { version: '2.27.0', manifest: { app: { ports: [{ host: 8083, auth: 'gated' }] } } },
|
||||
// Legacy curated installs — in the community list, NOT in the signed
|
||||
// catalog's manifests. Their ports publish plain HTTP: https fails.
|
||||
'nginx-proxy-manager': { version: 'latest' },
|
||||
tailscale: { version: 'stable' },
|
||||
// auth:none ports are container-published too — https would fail.
|
||||
cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } },
|
||||
},
|
||||
}
|
||||
|
||||
function stubLocation(value: { hostname: string; protocol: string }) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
beforeEach(() => {
|
||||
__setSignedCatalogForTests(SIGNED as never)
|
||||
})
|
||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
@@ -56,7 +87,9 @@ describe('appSessionConfig', () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
|
||||
// NetBird's dashboard needs a secure context (OIDC PKCE), so it is
|
||||
// ALWAYS launched over https — on either page scheme.
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('https://192.0.2.10:8087')
|
||||
})
|
||||
|
||||
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||
@@ -66,6 +99,52 @@ describe('appSessionConfig', () => {
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('http://192.0.2.10:8083')
|
||||
})
|
||||
|
||||
// The direct-port launch path (new-tab apps on desktop, the companion's
|
||||
// native WebView on phones) used to hardcode http:// — so a node reached
|
||||
// over HTTPS opened Vaultwarden and friends in cleartext. It must follow
|
||||
// the page scheme ONLY for ports the app gate fronts (TLS on the same
|
||||
// port); legacy installs without manifests (Nginx Proxy Manager, Tailscale)
|
||||
// and auth:none ports stay on http or https would fail to connect.
|
||||
it('builds direct app URLs on the page scheme — https page, gate-fronted app', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
|
||||
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
|
||||
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
|
||||
expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000')
|
||||
})
|
||||
|
||||
it('keeps legacy manifest-less apps on http even on an https page', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
|
||||
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
|
||||
expect(directAppUrl('tailscale')).toBe('http://192.0.2.10:8240')
|
||||
})
|
||||
|
||||
it('keeps plain-http direct app URLs on a plain-http page', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||
|
||||
expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082')
|
||||
expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081')
|
||||
})
|
||||
|
||||
it('always launches secure-context apps over https, on either page scheme', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'http:' })
|
||||
|
||||
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
|
||||
})
|
||||
|
||||
it('resolves session app URLs on the page scheme for gate-fronted ports only (https page)', () => {
|
||||
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080')
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('https://192.0.2.10:8083')
|
||||
// A runtime port the gate does NOT front keeps plain http (https would
|
||||
// fail to connect outright).
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
||||
// Cuprate's UI port is auth:none — plain HTTP stays plain.
|
||||
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
@@ -107,15 +108,20 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return appOrigin(8334)
|
||||
return appOrigin(8334, id)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
|
||||
// content and the frame is blocked outright, so follow the page instead.
|
||||
base = matchPageScheme(base)
|
||||
// binds locally. On an HTTPS dashboard that is mixed content and the
|
||||
// frame is blocked outright — but ONLY upgrade when the gate fronts the
|
||||
// port (it serves TLS there); a container-published plain-HTTP port
|
||||
// would fail to connect over https at all.
|
||||
try {
|
||||
const port = new URL(base).port
|
||||
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
|
||||
} catch { /* keep as-is */ }
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
@@ -124,13 +130,14 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = appOrigin(port)
|
||||
let base = appOrigin(port, id)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's origin on this host, on the SAME scheme as the page.
|
||||
* An app's origin on this host, on the SAME scheme as the page when the
|
||||
* app gate fronts the port (TLS on the same port), plain http otherwise.
|
||||
*
|
||||
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
||||
* mixed content before any cookie question arises — and it is also what makes
|
||||
@@ -143,8 +150,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||
* so an untrusted app port renders nothing rather than prompting.
|
||||
*/
|
||||
export function appOrigin(port: number): string {
|
||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||
export function appOrigin(port: number, appId?: string): string {
|
||||
const https = appId
|
||||
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
|
||||
: pageScheme() === 'https:'
|
||||
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||
@@ -166,6 +176,52 @@ function pageScheme(): string {
|
||||
return p === 'https:' || p === 'http:' ? p : 'http:'
|
||||
}
|
||||
|
||||
/** Apps served over HTTPS (self-signed) rather than plain HTTP, regardless of
|
||||
* the page's scheme. */
|
||||
export const HTTPS_APP_IDS = new Set(['netbird'])
|
||||
|
||||
/** App ID -> direct launch port for the paths that bypass the in-app session:
|
||||
* new-tab apps and the companion's native WebView. Every port here is owned
|
||||
* by the app gate (manifest `auth: gated`/`open` + `bind: 127.0.0.1`), which
|
||||
* serves TLS on the same port whenever the node has a certificate. */
|
||||
export const DIRECT_APP_PORTS: Record<string, string> = {
|
||||
'btcpay-server': '23000',
|
||||
grafana: '3000',
|
||||
photoprism: '2342',
|
||||
homeassistant: '8123',
|
||||
vaultwarden: '8082',
|
||||
nextcloud: '8085',
|
||||
portainer: '9000',
|
||||
tailscale: '8240',
|
||||
'nginx-proxy-manager': '8081',
|
||||
'uptime-kuma': '3002',
|
||||
gitea: '3001',
|
||||
// Without this, directAppUrl('netbird') returns null and netbird falls
|
||||
// through to the iframe (and never gets its https URL) — issue #15.
|
||||
netbird: '8087',
|
||||
}
|
||||
|
||||
/** Direct-port launch URL for an app, on the page's scheme.
|
||||
*
|
||||
* These are the apps that open OUTSIDE the dashboard's own origin — a new
|
||||
* browser tab on the desktop, or the companion's in-app WebView on a phone.
|
||||
* The URL is handed to a context with no dashboard chrome, so it must carry
|
||||
* the scheme the remote browser actually reached the node on: on an HTTPS
|
||||
* connection, `http://host:port` is at best a silent downgrade to cleartext
|
||||
* and at worst blocked outright as mixed content. Every port in
|
||||
* DIRECT_APP_PORTS is served by the app gate with TLS on the same port
|
||||
* (see appgate/tls.rs), so following the page scheme is always answerable.
|
||||
* Plain-HTTP dashboards keep today's behaviour exactly. */
|
||||
export function directAppUrl(appId: string): string | null {
|
||||
const port = DIRECT_APP_PORTS[appId]
|
||||
if (!port || typeof window === 'undefined') return null
|
||||
const scheme = HTTPS_APP_IDS.has(appId)
|
||||
|| (portIsGateFronted(appId, port) && pageScheme() === 'https:')
|
||||
? 'https'
|
||||
: 'http'
|
||||
return `${scheme}://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Resolve a human-readable title for an app */
|
||||
export function resolveAppTitle(id: string): string {
|
||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
@@ -3,35 +3,20 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
import {
|
||||
SERVICE_NAMES as SHARED_SERVICE_NAMES,
|
||||
isServiceContainer as sharedIsServiceContainer,
|
||||
} from './serviceNames'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
// Re-exported for every existing caller — the canon moved to serviceNames.ts
|
||||
// so the App Store's catalog merge can share it without a circular import.
|
||||
export const SERVICE_NAMES = SHARED_SERVICE_NAMES
|
||||
export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
@@ -42,16 +27,7 @@ export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): bo
|
||||
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
||||
}
|
||||
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
// isServiceContainer now re-exports the shared canon (serviceNames.ts).
|
||||
|
||||
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isServiceContainer(id)) return true
|
||||
@@ -299,7 +275,16 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
|
||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||
const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
// binds locally — on an HTTPS connection that is a cleartext downgrade.
|
||||
// Upgrade only when the app gate fronts the port (it serves TLS there);
|
||||
// a container-published plain-HTTP port would fail over https outright.
|
||||
try {
|
||||
const port = new URL(local).port
|
||||
if (!portIsGateFronted(pkg.manifest.id, port)) return local
|
||||
} catch { /* keep as-is */ }
|
||||
return matchPageScheme(local)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Canonical internal/service app-id classification — the shared source for
|
||||
* every surface that must decide "is this a user-facing app or a component
|
||||
* of one": the My Apps/Services tabs (appsConfig) and the App Store listing
|
||||
* (the signed-catalog merge in discover/curatedApps must not publish
|
||||
* components as standalone store apps).
|
||||
*
|
||||
* Keep this module dependency-free: it is imported from both the apps view
|
||||
* and the catalog module (which must not import view code that reads the
|
||||
* store — circulars).
|
||||
*/
|
||||
|
||||
// Service container ids (backend/infra, not user-facing). Mirrors the
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
|
||||
// Node-bundled internals that are real manifests (so they surface in the
|
||||
// signed catalog's manifest list) but are not store apps: the assistant and
|
||||
// the bundled relay/UIs ship with the node itself, the DWN is internal
|
||||
// plumbing, and the mesh router + CLN are components of other surfaces.
|
||||
export const NODE_INTERNAL_IDS = new Set([
|
||||
'aiui', 'fips-ui', 'strfry', 'web5-dwn', 'router', 'core-lightning',
|
||||
])
|
||||
|
||||
/** Is this id a component/backend rather than a standalone user app? */
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Should this app id appear in the App Store listing at all? */
|
||||
export function isStoreListedApp(id: string): boolean {
|
||||
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-14 h-14 rounded-lg object-cover"
|
||||
class="w-14 h-14 rounded-lg archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
Install button) opens the Remote Companion download/pairing modal.
|
||||
Not rendered at all inside the companion app's WebView: pitching
|
||||
"install the companion" to someone already in it is noise (#61). -->
|
||||
<div
|
||||
v-if="showPitch"
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
@@ -41,7 +44,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { isCompanionApp } from '@/utils/openExternal'
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
|
||||
const showPitch = computed(() => !isCompanionApp())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
class="w-20 h-20 rounded-xl archy-app-icon flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { isStoreListedApp } from '../apps/serviceNames'
|
||||
|
||||
const R = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
@@ -18,17 +19,99 @@ export interface AppCatalog {
|
||||
apps: MarketplaceApp[]
|
||||
}
|
||||
|
||||
/** Shape of the release-signed catalog (`releases/app-catalog.json`) served
|
||||
* by the daemon at /api/app-catalog after release-root verification. `apps`
|
||||
* is keyed by app id and each entry embeds the app's full manifest — the
|
||||
* ports[] there (auth: gated/open/none) are what decides whether a port is
|
||||
* fronted by the node's app gate (and therefore serves TLS on the same
|
||||
* port) or published by the container as plain HTTP. */
|
||||
export interface SignedAppCatalog {
|
||||
schema?: number
|
||||
updated?: string
|
||||
apps: Record<string, SignedAppEntry>
|
||||
}
|
||||
|
||||
export interface SignedAppEntry {
|
||||
version: string
|
||||
image?: string
|
||||
manifest?: {
|
||||
app?: {
|
||||
id?: string
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
category?: string
|
||||
container?: { image?: string }
|
||||
metadata?: { icon?: string; author?: string; repo?: string }
|
||||
ports?: { host?: number | string; container?: number | string; auth?: string }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert the signed catalog's keyed entries into store-listing apps.
|
||||
* Pure — unit-tested against the live catalog's shape (Cuprate).
|
||||
* Components and node internals never enter the listing: the signed catalog
|
||||
* carries every manifest (including backends like mempool-api and the
|
||||
* bundled UIs) because the node's update layer needs their pins, but the
|
||||
* App Store publishes apps, not parts of apps. */
|
||||
export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] {
|
||||
const out: MarketplaceApp[] = []
|
||||
for (const [id, entry] of Object.entries(catalog.apps || {})) {
|
||||
if (!isStoreListedApp(id)) continue
|
||||
const app = entry.manifest?.app
|
||||
out.push({
|
||||
id,
|
||||
title: app?.name || id,
|
||||
version: entry.version || app?.version || '',
|
||||
description: app?.description || '',
|
||||
icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg',
|
||||
author: app?.metadata?.author,
|
||||
dockerImage: entry.image || app?.container?.image || '',
|
||||
repoUrl: app?.metadata?.repo,
|
||||
category: app?.category,
|
||||
source: 'signed-catalog',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
|
||||
* after fetchAppCatalog() has run. Test-hookable. */
|
||||
let signedCatalogCache: SignedAppCatalog | null = null
|
||||
|
||||
/** Port auth for an app's host port, from the signed catalog's embedded
|
||||
* manifest. `gated`/`open` = the node's app gate owns the port and serves
|
||||
* TLS on it; `none`/`local` = container-published plain HTTP; null = app
|
||||
* unknown to the signed catalog (legacy curated installs). */
|
||||
export function portAuth(appId: string, hostPort: number | string): string | null {
|
||||
const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports
|
||||
if (!Array.isArray(ports)) return null
|
||||
const hit = ports.find(p => String(p.host) === String(hostPort))
|
||||
return hit?.auth ?? null
|
||||
}
|
||||
|
||||
/** Whether an app's host port is fronted by the node's app gate (and so
|
||||
* serves TLS alongside HTTP on the same port). Unknown apps are NOT —
|
||||
* assuming TLS for a container-published port breaks it outright. */
|
||||
export function portIsGateFronted(appId: string, hostPort: number | string): boolean {
|
||||
const auth = portAuth(appId, hostPort)
|
||||
return auth === 'gated' || auth === 'open'
|
||||
}
|
||||
|
||||
export function __setSignedCatalogForTests(catalog: SignedAppCatalog | null) {
|
||||
signedCatalogCache = catalog
|
||||
}
|
||||
|
||||
let cachedCatalog: AppCatalog | null = null
|
||||
let catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
/** Catalog URLs for the community listing. The signed catalog is served by
|
||||
* the backend proxy (`/api/app-catalog`) — server-side fetch bypasses CORS
|
||||
* on the upstream Gitea and verifies the release-root signature. If the
|
||||
* backend is offline (mid-restart etc.) the static community copy baked
|
||||
* into the frontend build still renders the store. */
|
||||
const CATALOG_URLS = [
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
@@ -38,29 +121,61 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
// Return cache if fresh
|
||||
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
|
||||
|
||||
// The daemon-verified signed catalog first (release-root signature checked
|
||||
// server-side): it is what makes a newly published app appear without a
|
||||
// dashboard release. The community catalog supplies the featured banner
|
||||
// and curated copy for shared ids; signed-only ids join the listing as-is.
|
||||
let signedApps: MarketplaceApp[] = []
|
||||
let signedOk = false
|
||||
try {
|
||||
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as SignedAppCatalog
|
||||
if (data.apps && !Array.isArray(data.apps)) {
|
||||
signedCatalogCache = data
|
||||
signedApps = signedCatalogToApps(data)
|
||||
signedOk = signedApps.length > 0
|
||||
}
|
||||
}
|
||||
} catch { /* fall through to the community catalog */ }
|
||||
|
||||
let community: AppCatalog | null = null
|
||||
for (const url of CATALOG_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
catalogFetchedAt = Date.now()
|
||||
// Cache in localStorage for offline fallback
|
||||
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(`[catalog] fetch failed for ${url}:`, e)
|
||||
continue
|
||||
community = data
|
||||
break
|
||||
} catch { /* try the next source */ }
|
||||
}
|
||||
|
||||
if (signedOk || community) {
|
||||
// Community copy wins for shared ids (curated descriptions, webUrl-only
|
||||
// apps); signed entries fill version/image gaps and append brand-new apps.
|
||||
const byId = new Map<string, MarketplaceApp>()
|
||||
for (const app of signedApps) byId.set(app.id, app)
|
||||
for (const app of community?.apps ?? []) {
|
||||
const existing = byId.get(app.id)
|
||||
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app)
|
||||
}
|
||||
const merged: AppCatalog = {
|
||||
version: community?.version ?? 1,
|
||||
registry: community?.registry ?? R,
|
||||
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
|
||||
apps: [...byId.values()],
|
||||
}
|
||||
cachedCatalog = merged
|
||||
catalogFetchedAt = Date.now()
|
||||
try { localStorage.setItem('archy_catalog', JSON.stringify(merged)) } catch {}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Try localStorage cache as final fallback
|
||||
@@ -91,7 +206,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
|
||||
@@ -247,17 +247,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/ollama/ollama'
|
||||
},
|
||||
{
|
||||
id: 'cryptpad',
|
||||
title: 'CryptPad',
|
||||
version: '2024.12.0',
|
||||
description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.',
|
||||
icon: '/assets/icon/favico-black-v2.svg',
|
||||
author: 'XWiki SAS',
|
||||
dockerImage: `${REGISTRY}/cryptpad:2024.12.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/cryptpad/cryptpad'
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
title: 'Nextcloud',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
interface SshOverMeshStatus {
|
||||
enabled: boolean
|
||||
sources: string[]
|
||||
scope: 'any' | 'list'
|
||||
preflights?: {
|
||||
sshd_active: boolean
|
||||
sshd_ipv6_listen: boolean
|
||||
password_auth: boolean | null
|
||||
}
|
||||
applied?: boolean
|
||||
reloaded?: boolean
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const status = ref<SshOverMeshStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
const confirmAnyPeer = ref(false)
|
||||
const pendingEnableAny = ref(false)
|
||||
const sourcesText = ref('')
|
||||
const fipsNpub = ref<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
status.value = (await rpcClient.call({ method: 'fips.ssh-over-mesh.get' })) as SshOverMeshStatus
|
||||
sourcesText.value = (status.value.sources || []).join('\n')
|
||||
if (!fipsNpub.value) {
|
||||
try {
|
||||
const s = (await rpcClient.call<{ npub?: string }>({ method: 'fips.status', dedup: true, maxRetries: 1 }))
|
||||
fipsNpub.value = s.npub ?? null
|
||||
} catch { /* npub hint is optional */ }
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load SSH-over-mesh state'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The unrestricted scope opens port 22 to every mesh peer — demand an
|
||||
* explicit confirmation before applying it, like the other danger zones. */
|
||||
async function toggle(enabled: boolean) {
|
||||
const sources = enabled && status.value?.scope === 'list'
|
||||
? sourcesText.value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
|
||||
: (status.value?.sources ?? [])
|
||||
if (enabled && sources.length === 0) {
|
||||
pendingEnableAny.value = true
|
||||
confirmAnyPeer.value = true
|
||||
return
|
||||
}
|
||||
await apply(enabled, sources)
|
||||
}
|
||||
|
||||
async function apply(enabled: boolean, sources: string[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
status.value = (await rpcClient.call({
|
||||
method: 'fips.ssh-over-mesh.set',
|
||||
params: { enabled, sources },
|
||||
timeout: 30_000,
|
||||
})) as SshOverMeshStatus
|
||||
sourcesText.value = (status.value.sources || []).join('\n')
|
||||
toast.success(enabled ? 'SSH over mesh allowed' : 'SSH over mesh blocked')
|
||||
if (enabled && status.value.reloaded === false) {
|
||||
toast.error('Rule saved, but the firewall reload failed — reconnect or check logs')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to apply')
|
||||
} finally {
|
||||
saving.value = false
|
||||
pendingEnableAny.value = false
|
||||
confirmAnyPeer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyFipssh() {
|
||||
if (!fipsNpub.value) return
|
||||
const cmd = `fipssh <user>@${fipsNpub.value}`
|
||||
navigator.clipboard?.writeText(cmd).then(
|
||||
() => toast.success('Copied: ' + cmd),
|
||||
() => toast.error('Copy failed'),
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">SSH over mesh</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
Let the phone's FIPS mesh reach this node's SSH (port 22). Off by default — the mesh
|
||||
firewall refuses SSH until you allow it here.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="path-action-button path-action-button--continue px-4 py-2 text-sm shrink-0"
|
||||
:disabled="saving || loading"
|
||||
:class="{ '!bg-orange-500/30 !border-orange-400/50': status?.enabled }"
|
||||
@click="toggle(!status?.enabled)"
|
||||
>
|
||||
{{ status?.enabled ? 'Allowed — block again' : 'Allow SSH over mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50 py-3">Loading…</div>
|
||||
<div v-else-if="error" class="text-sm text-red-300 py-3">{{ error }}
|
||||
<button class="ml-2 underline" @click="refresh">Retry</button>
|
||||
</div>
|
||||
<template v-else-if="status">
|
||||
<!-- Preflights: explain the toggle rather than gating it -->
|
||||
<div class="mt-3 space-y-1.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.sshd_active ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">sshd {{ status.preflights?.sshd_active ? 'is running' : 'is NOT running' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.sshd_ipv6_listen ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">
|
||||
{{ status.preflights?.sshd_ipv6_listen ? 'listens on IPv6 (mesh-reachable)' : 'does not listen on IPv6 — the mesh cannot reach it' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.password_auth === false ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">
|
||||
{{ status.preflights?.password_auth == null
|
||||
? 'PasswordAuthentication unknown'
|
||||
: status.preflights?.password_auth
|
||||
? 'password login allowed — keys-only is the safer pairing for the firewall rule'
|
||||
: 'keys-only login (recommended)' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source restriction -->
|
||||
<div class="mt-4">
|
||||
<p class="text-xs text-white/50 mb-1">Restrict to specific mesh addresses (one per line), or leave empty to allow any mesh peer.</p>
|
||||
<textarea
|
||||
v-model="sourcesText"
|
||||
rows="2"
|
||||
class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-sm text-white/90 font-mono placeholder-white/30"
|
||||
placeholder="fd79:… phone mesh address"
|
||||
:disabled="!status.enabled || saving"
|
||||
@change="apply(true, sourcesText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Phone-side hint: the npub is the durable address -->
|
||||
<div v-if="status.enabled && fipsNpub" class="mt-3 flex items-center gap-2 text-sm">
|
||||
<span class="text-white/60">From Termux:</span>
|
||||
<code class="text-orange-200 truncate max-w-[55%]">fipssh <user>@{{ fipsNpub.slice(0, 12) }}…</code>
|
||||
<button class="glass-button px-2 py-1 text-xs" @click="copyFipssh">Copy</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Danger-zone confirmation for the unrestricted scope -->
|
||||
<Teleport to="body">
|
||||
<div v-if="confirmAnyPeer" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="confirmAnyPeer = false">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">Allow SSH from ANY mesh peer?</h3>
|
||||
<p class="text-sm text-white/70 mb-4">
|
||||
Anyone who can route to this node over the FIPS mesh will reach port 22. Restricting
|
||||
to your phone's mesh address above is the safer pairing. Only continue if you
|
||||
understand port 22 will face every mesh peer.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 glass-button px-4 py-2 text-sm" @click="confirmAnyPeer = false">Restrict instead</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 rounded-lg text-sm border border-orange-400/50 bg-orange-500/20 text-orange-200 hover:bg-orange-500/30"
|
||||
:disabled="saving || pendingEnableAny === false"
|
||||
@click="apply(true, [])"
|
||||
>I understand — allow any</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import SshOverMeshCard from '@/views/settings/SshOverMeshCard.vue'
|
||||
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
|
||||
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@@ -20,6 +21,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<SshOverMeshCard />
|
||||
<NodeCertificateSection />
|
||||
<LightningCredentialsSection />
|
||||
<BackupSection />
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const getSpy = vi.fn()
|
||||
const setSpy = vi.fn()
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn() }
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (req: { method: string; params?: unknown }) => {
|
||||
if (req.method === 'fips.ssh-over-mesh.get') return getSpy(req)
|
||||
if (req.method === 'fips.ssh-over-mesh.set') return setSpy(req)
|
||||
if (req.method === 'fips.status') return Promise.resolve({ npub: 'npub1test' })
|
||||
return Promise.resolve({})
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
|
||||
|
||||
import SshOverMeshCard from '../SshOverMeshCard.vue'
|
||||
|
||||
function statusBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: false,
|
||||
sources: [],
|
||||
scope: 'any',
|
||||
preflights: { sshd_active: true, sshd_ipv6_listen: true, password_auth: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshOverMeshCard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getSpy.mockResolvedValue(statusBody())
|
||||
setSpy.mockResolvedValue(statusBody({ enabled: true }))
|
||||
})
|
||||
|
||||
it('renders preflights from the RPC state', async () => {
|
||||
getSpy.mockResolvedValue(statusBody())
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await vi.dynamicImportSettled()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(getSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'fips.ssh-over-mesh.get' }))
|
||||
expect(wrapper.text()).toContain('sshd is running')
|
||||
expect(wrapper.text()).toContain('keys-only login')
|
||||
})
|
||||
|
||||
it('flags the missing IPv6 listener honestly', async () => {
|
||||
getSpy.mockResolvedValue(statusBody({
|
||||
preflights: { sshd_active: true, sshd_ipv6_listen: false, password_auth: true },
|
||||
}))
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(wrapper.text()).toContain('does not listen on IPv6')
|
||||
expect(wrapper.text()).toContain('keys-only is the safer pairing')
|
||||
})
|
||||
|
||||
it('demands the danger-zone confirmation before an unrestricted enable', async () => {
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await wrapper.find('button.path-action-button').trigger('click')
|
||||
// The confirmation modal Teleports to <body>; nothing applied yet.
|
||||
expect(document.body.textContent).toContain('Allow SSH from ANY mesh peer')
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
// Confirming applies the unrestricted rule.
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find(b => (b.textContent ?? '').includes('I understand'))
|
||||
expect(confirm).toBeDefined()
|
||||
confirm!.dispatchEvent(new Event('click'))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: { enabled: true, sources: [] },
|
||||
}))
|
||||
})
|
||||
|
||||
it('applies a restricted enable without the any-peer confirmation', async () => {
|
||||
// An existing restricted rule: turning it back on re-applies the list.
|
||||
getSpy.mockResolvedValue(statusBody({ enabled: false, sources: ['fd00::1'], scope: 'list' }))
|
||||
setSpy.mockResolvedValue(statusBody({ enabled: true, sources: ['fd00::1'], scope: 'list' }))
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await wrapper.find('button.path-action-button').trigger('click')
|
||||
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: { enabled: true, sources: ['fd00::1'] },
|
||||
}))
|
||||
expect(wrapper.text()).not.toContain('Allow SSH from ANY mesh peer')
|
||||
})
|
||||
})
|
||||
+19
-18
@@ -1,31 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
|
||||
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
|
||||
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
|
||||
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
|
||||
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
|
||||
"**What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.",
|
||||
"**The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.",
|
||||
"**Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.",
|
||||
"**Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.",
|
||||
"**Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.",
|
||||
"**Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"current_version": "1.8.7-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
"new_version": "1.8.7-alpha",
|
||||
"sha256": "572accec81e73fbcd5218ddc41f5ec719deda30f104c838398fdfa85a7a276f0",
|
||||
"size_bytes": 63990960
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
"current_version": "1.8.7-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago-frontend-1.8.7-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.7-alpha.tar.gz",
|
||||
"new_version": "1.8.7-alpha",
|
||||
"sha256": "26b8c12b1b3e6b5c93841f3713037f65d0ea6a52401bd28888eb8d2f5dc483b5",
|
||||
"size_bytes": 97779181
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signature": "79332435d436e7bae1a5b0c12158b02083b681b6818ac90219021f1e39eb61a3e1039ed38ee8089006a0dec862fd92997e44a6c9ccf0be17e91a95cba5de6402",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.5-alpha"
|
||||
"version": "1.8.7-alpha"
|
||||
}
|
||||
|
||||
+325
-271
@@ -2,6 +2,98 @@
|
||||
"apps": {
|
||||
"adguardhome": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
|
||||
"network": "pasta",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
|
||||
"environment": [],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:3030",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "adguardhome",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "AdGuard Home web console",
|
||||
"name": "Admin console",
|
||||
"path": "/",
|
||||
"port": 3030,
|
||||
"protocol": "http",
|
||||
"type": "ui"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"author": "AdGuard",
|
||||
"category": "networking",
|
||||
"repo": "https://github.com/AdguardTeam/AdGuardHome",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "AdGuard Home",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "open",
|
||||
"auth_rationale": "AdGuard Home enforces its own admin login on the console, and the first-run wizard must answer before any account exists.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3030,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Plain DNS answers unauthenticated by protocol: resolvers and clients send queries directly; a login challenge would make DNS unreachable.",
|
||||
"container": 53,
|
||||
"host": 53,
|
||||
"protocol": "udp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "DNS-over-TCP fallback (truncated responses, zone transfers); same protocol-level requirement as the UDP port.",
|
||||
"container": 53,
|
||||
"host": 53,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "1Gi",
|
||||
"memory_limit": "512Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"NET_BIND_SERVICE"
|
||||
],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "AdguardTeam/AdGuardHome"
|
||||
},
|
||||
"version": "v0.107.55",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/adguardhome",
|
||||
"target": "/opt/adguardhome",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "v0.107.55"
|
||||
},
|
||||
"aiui": {
|
||||
@@ -1205,10 +1297,6 @@
|
||||
},
|
||||
"version": "23.08.2"
|
||||
},
|
||||
"cryptpad": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
|
||||
"version": "2024.12.0"
|
||||
},
|
||||
"cuprate": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -1299,79 +1387,6 @@
|
||||
},
|
||||
"version": "0.1.0-preview"
|
||||
},
|
||||
"did-wallet": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "archipelago/did-wallet:1.0.0",
|
||||
"image_signature": "cosign://...",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "2Gi"
|
||||
}
|
||||
],
|
||||
"description": "Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.",
|
||||
"environment": [
|
||||
"WALLET_STORAGE=/app/wallet"
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:8080",
|
||||
"interval": "30s",
|
||||
"path": "/health",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "did-wallet",
|
||||
"name": "Web5 DID Wallet",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8088,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 1,
|
||||
"disk_limit": "2Gi",
|
||||
"memory_limit": "512Mi"
|
||||
},
|
||||
"security": {
|
||||
"apparmor_profile": "did-wallet",
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true,
|
||||
"seccomp_profile": "default",
|
||||
"user": 1000
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "internal"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/did-wallet",
|
||||
"target": "/app/wallet",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
"web5_integration": {
|
||||
"bitcoin_integration": true,
|
||||
"did_support": true,
|
||||
"wallet_functionality": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"electrs-ui": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
@@ -1951,10 +1966,6 @@
|
||||
},
|
||||
"version": "v2.27.0"
|
||||
},
|
||||
"fips": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/fips:v0.1.0",
|
||||
"version": "v0.1.0"
|
||||
},
|
||||
"fips-ui": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -3196,106 +3207,6 @@
|
||||
},
|
||||
"version": "10.11.11"
|
||||
},
|
||||
"lightning-stack": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"bitcoin_integration": {
|
||||
"rpc_access": "admin",
|
||||
"sync_required": true
|
||||
},
|
||||
"container": {
|
||||
"image": "lightninglabs/lightning-stack:v0.12.0",
|
||||
"image_signature": "cosign://...",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"app_id": "bitcoin-core",
|
||||
"version": ">=24.0"
|
||||
},
|
||||
{
|
||||
"storage": "50Gi"
|
||||
}
|
||||
],
|
||||
"description": "Complete Lightning Network implementation. Includes LND, CLN, and management tools.",
|
||||
"environment": [
|
||||
"BITCOIND_HOST=bitcoin-core",
|
||||
"BITCOIND_RPCUSER=${BITCOIN_RPC_USER}",
|
||||
"BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}",
|
||||
"NETWORK=mainnet"
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:8080",
|
||||
"interval": "30s",
|
||||
"path": "/v1/getinfo",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "lightning-stack",
|
||||
"lightning_integration": {
|
||||
"channel_management": true,
|
||||
"payment_routing": true
|
||||
},
|
||||
"name": "Lightning Stack",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9738,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.",
|
||||
"container": 10009,
|
||||
"host": 10010,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.",
|
||||
"container": 8080,
|
||||
"host": 8091,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 4,
|
||||
"disk_limit": "50Gi",
|
||||
"memory_limit": "4Gi"
|
||||
},
|
||||
"security": {
|
||||
"apparmor_profile": "lightning-stack",
|
||||
"capabilities": [
|
||||
"NET_BIND_SERVICE"
|
||||
],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true,
|
||||
"seccomp_profile": "default",
|
||||
"user": 1000
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "manual",
|
||||
"url": "no public listing for lightninglabs/lightning-stack — verify by hand"
|
||||
},
|
||||
"version": "0.12.0",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/lightning-stack",
|
||||
"target": "/root/.lightning",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "0.12.0"
|
||||
},
|
||||
"lnd": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"manifest": {
|
||||
@@ -3651,75 +3562,6 @@
|
||||
},
|
||||
"version": "3.0.0"
|
||||
},
|
||||
"morphos-server": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "archipelago/morphos-server:1.0.0",
|
||||
"image_signature": "cosign://...",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "5Gi"
|
||||
}
|
||||
],
|
||||
"description": "MorphOS server platform. Decentralized application server.",
|
||||
"environment": [
|
||||
"MORPHOS_ENV=production",
|
||||
"MORPHOS_DATA_DIR=/app/data"
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:8080",
|
||||
"interval": "30s",
|
||||
"path": "/health",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "morphos-server",
|
||||
"name": "MorphOS Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8089,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_limit": 2,
|
||||
"disk_limit": "5Gi",
|
||||
"memory_limit": "2Gi"
|
||||
},
|
||||
"security": {
|
||||
"apparmor_profile": "morphos-server",
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": true,
|
||||
"seccomp_profile": "default",
|
||||
"user": 1000
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "internal"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/morphos-server",
|
||||
"target": "/app/data",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"netbird": {
|
||||
"manifest": {
|
||||
"app": {
|
||||
@@ -4145,6 +3987,88 @@
|
||||
},
|
||||
"nginx-proxy-manager": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"network": "pasta",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"environment": [],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:81",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "nginx-proxy-manager",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Nginx Proxy Manager admin interface",
|
||||
"name": "Admin UI",
|
||||
"path": "/",
|
||||
"port": 8081,
|
||||
"protocol": "http",
|
||||
"type": "ui"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"repo": "https://github.com/NginxProxyManager/nginx-proxy-manager",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Nginx Proxy Manager",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "open",
|
||||
"auth_rationale": "Nginx Proxy Manager enforces its own admin account on every page; the initial setup wizard also has to answer before any account exists.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 81,
|
||||
"host": 8081,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "1Gi",
|
||||
"memory_limit": "512Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"CHOWN",
|
||||
"SETUID",
|
||||
"SETGID",
|
||||
"DAC_OVERRIDE"
|
||||
],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
"version": "2.12.1",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/nginx-proxy-manager",
|
||||
"target": "/data",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "latest"
|
||||
},
|
||||
"nostr-rs-relay": {
|
||||
@@ -4226,24 +4150,74 @@
|
||||
},
|
||||
"version": "0.10.0"
|
||||
},
|
||||
"nostr-vpn": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/nostr-vpn:v0.3.7",
|
||||
"version": "v0.3.7"
|
||||
},
|
||||
"ollama": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/ollama:latest",
|
||||
"version": "latest"
|
||||
},
|
||||
"penpot": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/penpot-frontend:2.4",
|
||||
"images": {
|
||||
"penpot-backend": "source.archipelago-foundation.org/lfg2025/penpot-backend:2.4",
|
||||
"penpot-exporter": "source.archipelago-foundation.org/lfg2025/penpot-exporter:2.4",
|
||||
"penpot-frontend": "source.archipelago-foundation.org/lfg2025/penpot-frontend:2.4",
|
||||
"penpot-postgres": "source.archipelago-foundation.org/lfg2025/postgres:15",
|
||||
"penpot-valkey": "source.archipelago-foundation.org/lfg2025/valkey:8.1"
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/ollama:latest",
|
||||
"network": "pasta",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "50Gi"
|
||||
}
|
||||
],
|
||||
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
|
||||
"environment": [],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:11434",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "ollama",
|
||||
"metadata": {
|
||||
"author": "Ollama",
|
||||
"category": "community",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"repo": "https://github.com/ollama/ollama",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Ollama",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 11434,
|
||||
"host": 11434,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "50Gi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "ollama/ollama"
|
||||
},
|
||||
"version": "0.5.4",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/ollama",
|
||||
"target": "/root/.ollama",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "2.4"
|
||||
"version": "latest"
|
||||
},
|
||||
"phoenixd": {
|
||||
"manifest": {
|
||||
@@ -5030,10 +5004,6 @@
|
||||
},
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"routstr": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/routstr:v0.4.3",
|
||||
"version": "v0.4.3"
|
||||
},
|
||||
"searxng": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/searxng:latest",
|
||||
"manifest": {
|
||||
@@ -5190,6 +5160,90 @@
|
||||
},
|
||||
"tailscale": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"entrypoint": [
|
||||
"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"
|
||||
],
|
||||
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
|
||||
"network": "pasta",
|
||||
"pull_policy": "if-not-present"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"storage": "1Gi"
|
||||
}
|
||||
],
|
||||
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||
"environment": [
|
||||
"TS_STATE_DIR=/var/lib/tailscale"
|
||||
],
|
||||
"health_check": {
|
||||
"endpoint": "localhost:8240",
|
||||
"interval": "30s",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "tcp"
|
||||
},
|
||||
"id": "tailscale",
|
||||
"interfaces": {
|
||||
"main": {
|
||||
"description": "Tailscale web console",
|
||||
"name": "Web console",
|
||||
"path": "/",
|
||||
"port": 8240,
|
||||
"protocol": "http",
|
||||
"type": "ui"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"author": "Tailscale",
|
||||
"category": "networking",
|
||||
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||
"repo": "https://github.com/tailscale/tailscale",
|
||||
"tier": "recommended"
|
||||
},
|
||||
"name": "Tailscale",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "open",
|
||||
"auth_rationale": "Tailscale's web console authenticates against the tailnet account for all administrative actions; the node's cookie challenge would be a second, redundant login.",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8240,
|
||||
"host": 8240,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"disk_limit": "1Gi",
|
||||
"memory_limit": "512Mi"
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [],
|
||||
"network_policy": "isolated",
|
||||
"no_new_privileges": true,
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "github",
|
||||
"repo": "tailscale/tailscale"
|
||||
},
|
||||
"version": "1.78.0",
|
||||
"volumes": [
|
||||
{
|
||||
"options": [
|
||||
"rw"
|
||||
],
|
||||
"source": "/var/lib/archipelago/tailscale",
|
||||
"target": "/var/lib/tailscale",
|
||||
"type": "bind"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "stable"
|
||||
},
|
||||
"uptime-kuma": {
|
||||
@@ -5366,7 +5420,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "da5b6b183ac46c062945c27abdc06affb558e805e1ccf67ac0ee17e5e3dd85cc05a0656dd83bdacb1e1d237445145d00995f55e77209e1cbb2b6d8ce47084e0a",
|
||||
"signature": "03941cb0dd7ec0ce344e4231da40606f1ef24d34a7f1f872622c42bd982846ec622e5161839a68824be4d386fcc68c4958624daf5249897c620a835dc2d11304",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-30"
|
||||
"updated": "2026-09-01"
|
||||
}
|
||||
|
||||
+19
-18
@@ -1,31 +1,32 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
|
||||
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
|
||||
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
|
||||
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
|
||||
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
|
||||
"**What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.",
|
||||
"**The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.",
|
||||
"**Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.",
|
||||
"**Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.",
|
||||
"**Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.",
|
||||
"**Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
|
||||
"current_version": "1.8.7-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
|
||||
"size_bytes": 63934504
|
||||
"new_version": "1.8.7-alpha",
|
||||
"sha256": "572accec81e73fbcd5218ddc41f5ec719deda30f104c838398fdfa85a7a276f0",
|
||||
"size_bytes": 63990960
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.5-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
|
||||
"new_version": "1.8.5-alpha",
|
||||
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
|
||||
"size_bytes": 97657151
|
||||
"current_version": "1.8.7-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.7-alpha/archipelago-frontend-1.8.7-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.7-alpha.tar.gz",
|
||||
"new_version": "1.8.7-alpha",
|
||||
"sha256": "26b8c12b1b3e6b5c93841f3713037f65d0ea6a52401bd28888eb8d2f5dc483b5",
|
||||
"size_bytes": 97779181
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-31",
|
||||
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
|
||||
"signature": "79332435d436e7bae1a5b0c12158b02083b681b6818ac90219021f1e39eb61a3e1039ed38ee8089006a0dec862fd92997e44a6c9ccf0be17e91a95cba5de6402",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.5-alpha"
|
||||
"version": "1.8.7-alpha"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**SSH over the mesh is now a first-class setting.** Settings gains an \"SSH over mesh\" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit \"I understand\" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.",
|
||||
"**The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).",
|
||||
"**App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.",
|
||||
"**Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.8-alpha",
|
||||
"sha256": "96f39b8db6f08386200e1eab91c8444a7758526e6034100c8a33907ff9263530",
|
||||
"size_bytes": 64175864
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.8-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.8-alpha/archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.8-alpha.tar.gz",
|
||||
"new_version": "1.8.8-alpha",
|
||||
"sha256": "7829b67edf8dec27997dd821650ed4d61aea721f802d46a8d47014f4b4246db1",
|
||||
"size_bytes": 97730549
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-01",
|
||||
"signature": "c839cbdcb356a503d87bc17f52b6e5f3a934ae1e72a891f2d21d85366f23debb224a2a40b9124bab50fe95444e01e27711690b1bc50062f40b7ed4f34e078d06",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.8-alpha"
|
||||
}
|
||||
@@ -19,13 +19,10 @@ INTERNAL_MANIFEST_IDS = {
|
||||
"archy-nbxplorer",
|
||||
"bitcoin-ui",
|
||||
"core-lightning",
|
||||
"did-wallet",
|
||||
"electrs-ui",
|
||||
"fips-ui",
|
||||
"lightning-stack",
|
||||
"lnd-ui",
|
||||
"mempool-api",
|
||||
"morphos-server",
|
||||
"router",
|
||||
"strfry",
|
||||
"web5-dwn",
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Validate releases/manifest.json:
|
||||
# Validate the live or a pending release manifest:
|
||||
# - version matches core/archipelago/Cargo.toml
|
||||
# - changelog contains curated release notes, not raw git log output
|
||||
# - every component's download_url exists on disk and matches sha256/size
|
||||
#
|
||||
# Run on every push from CI, and also locally before publishing a release:
|
||||
# scripts/check-release-manifest.sh
|
||||
# scripts/check-release-manifest.sh [path/to/manifest.json]
|
||||
#
|
||||
# Exits non-zero on any mismatch so the release process fails loud.
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MANIFEST="$REPO_ROOT/releases/manifest.json"
|
||||
MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}"
|
||||
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST"
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "❌ releases/manifest.json missing"
|
||||
echo "❌ manifest missing: $MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -25,6 +26,18 @@ ok() { echo "✅ $*"; }
|
||||
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
||||
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
|
||||
# A prepared release deliberately leaves the live manifest on the previous
|
||||
# version. Ordinary pushes are therefore harmless: only the publisher promotes
|
||||
# the pending manifest after its assets have been uploaded and downloaded back.
|
||||
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then
|
||||
PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json"
|
||||
if [ -f "$PENDING" ]; then
|
||||
ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending"
|
||||
MANIFEST="$PENDING"
|
||||
MANIFEST_VERSION="$CARGO_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
|
||||
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
|
||||
fi
|
||||
@@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
|
||||
done
|
||||
|
||||
echo
|
||||
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}"
|
||||
ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}"
|
||||
|
||||
@@ -261,15 +261,19 @@ content = open('$CHANGELOG_FILE').read()
|
||||
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
|
||||
m = re.search(pattern, content, re.DOTALL)
|
||||
if m:
|
||||
for line in m.group(1).strip().split('\n')[:10]:
|
||||
for line in m.group(1).splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
print(line)
|
||||
if not line.startswith('- '):
|
||||
continue
|
||||
text = line[2:].strip()
|
||||
if text.lower().startswith('validation '):
|
||||
continue
|
||||
print(text)
|
||||
" 2>/dev/null || echo "")
|
||||
if [ -n "$ENTRIES" ]; then
|
||||
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
|
||||
import sys, json
|
||||
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
|
||||
lines = [l.strip() for l in sys.stdin if l.strip()]
|
||||
print(json.dumps(lines))
|
||||
")
|
||||
fi
|
||||
@@ -298,7 +302,7 @@ echo ""
|
||||
cat "$OUTPUT_FILE"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review the manifest above"
|
||||
echo " 2. Upload artifacts to Gitea release v$VERSION"
|
||||
echo " 3. Commit manifest.json to releases/manifest.json on main"
|
||||
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
|
||||
echo " 1. Review and sign the manifest above"
|
||||
echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
|
||||
echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
|
||||
echo " (it uploads + verifies assets before atomically promoting the manifest)"
|
||||
|
||||
+39
-49
@@ -2,7 +2,8 @@
|
||||
# create-release.sh — Full release automation for Archipelago
|
||||
#
|
||||
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
|
||||
# creates release manifest, and creates git tag.
|
||||
# creates a pending release manifest, and creates git tag. The live manifest is
|
||||
# promoted only by publish-release-assets.sh after the assets are verified.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
|
||||
@@ -30,9 +31,9 @@ for arg in "$@"; do
|
||||
echo " 2. Bump version in Cargo.toml and package.json"
|
||||
echo " 3. Build backend"
|
||||
echo " 4. Build frontend"
|
||||
echo " 5. Generate changelog from git log"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit version bump"
|
||||
echo " 5. Validate the curated changelog"
|
||||
echo " 6. Create pending release manifest"
|
||||
echo " 7. Commit release preparation"
|
||||
echo " 8. Create git tag v{VERSION}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
@@ -121,14 +122,13 @@ if $DRY_RUN; then
|
||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||
echo " 4. Build frontend (npm run build)"
|
||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 5. Validate the curated changelog"
|
||||
echo " 6. Create pending release manifest (the live manifest stays unchanged)"
|
||||
echo " 7. Commit: 'chore: prepare release v${VERSION}'"
|
||||
echo " 8. Tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "After this script, you would:"
|
||||
echo " - Push: git push && git push --tags"
|
||||
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
|
||||
echo "After this script, publish only with:"
|
||||
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[6/8] Creating release manifest..."
|
||||
mkdir -p "$PROJECT_ROOT/releases"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||
echo "[6/8] Creating pending release manifest..."
|
||||
# Never write the fleet-visible path here. A normal `git push main` must not be
|
||||
# capable of advertising assets which have not been uploaded yet.
|
||||
PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}"
|
||||
PENDING_MANIFEST="$PENDING_DIR/manifest.json"
|
||||
mkdir -p "$PENDING_DIR"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$"
|
||||
|
||||
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
||||
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
||||
@@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
|
||||
echo " Enter by itself will NOT submit; pasting twice concatenates"
|
||||
echo " the phrases and fails on word count."
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
|
||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
|
||||
"$SIGNER" ceremony sign "$PENDING_MANIFEST"
|
||||
"$SIGNER" ceremony verify "$PENDING_MANIFEST"
|
||||
else
|
||||
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
|
||||
echo " This run will ABORT before committing (step 7 refuses an unsigned"
|
||||
echo " manifest), because nodes read releases/manifest.json from branch main"
|
||||
echo " and would refuse to auto-apply it."
|
||||
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
|
||||
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED."
|
||||
echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)."
|
||||
echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST"
|
||||
fi
|
||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||
|
||||
echo "[6c/8] Staging release artifacts for validation..."
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
mkdir -p "$VERSION_DIR"
|
||||
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
||||
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
"$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST"
|
||||
|
||||
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
|
||||
# because publishing is not the first way an unsigned manifest reaches the
|
||||
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
|
||||
# (see the verification URLs printed below), so the COMMIT is what exposes
|
||||
# it, not the publish. publish-release-assets.sh refusing to ship is a
|
||||
# backstop that arrives one step too late: by then the unsigned manifest is
|
||||
# already on main and the fleet is already refusing to auto-apply.
|
||||
#
|
||||
# This is why every cycle needed a manual catch. The signing block above is
|
||||
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
|
||||
# warning and falls through — and the commit then happened anyway. A release
|
||||
# commit carrying a manifest no node will accept has no valid use, so refuse
|
||||
# to create one rather than leave a tag that has to be re-cut.
|
||||
# §A supply-chain gate, mirroring publish-release-assets.sh. The pending path
|
||||
# prevents an ordinary main push from exposing the release, but an unsigned
|
||||
# manifest is still unpublishable and must never be tagged as ready.
|
||||
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
|
||||
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
|
||||
# pin on every node. From v1.7.123 onward the new root signs, and nodes
|
||||
# running .122+ reject anything signed with the old key.
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
|
||||
if ! grep -q '"signature":' "$PENDING_MANIFEST" \
|
||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then
|
||||
echo "" >&2
|
||||
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
|
||||
echo " Refusing to commit — nodes read this file from branch main and will" >&2
|
||||
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
|
||||
echo "Error: the pending manifest is NOT signed by the release root." >&2
|
||||
echo " Refusing to commit an unpublishable release." >&2
|
||||
echo "" >&2
|
||||
echo " Sign it, then re-run this script:" >&2
|
||||
echo " bash scripts/sign-manifest.sh" >&2
|
||||
echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2
|
||||
echo "" >&2
|
||||
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
||||
"$SIGNER" ceremony verify "$PENDING_MANIFEST" \
|
||||
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
|
||||
|
||||
echo "[7/8] Committing version bump..."
|
||||
echo "[7/8] Committing release preparation..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
core/Cargo.lock \
|
||||
@@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \
|
||||
neode-ui/package-lock.json \
|
||||
neode-ui/public/catalog.json \
|
||||
CHANGELOG.md \
|
||||
releases/manifest.json \
|
||||
release-manifest.json \
|
||||
2>/dev/null || true
|
||||
# releases/** is ignored because binaries live in Gitea attachments; force-add
|
||||
# only this small signed pending manifest.
|
||||
git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json"
|
||||
# Cargo.lock (rewritten by the release build after the version bump) and
|
||||
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
|
||||
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
|
||||
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}"
|
||||
|
||||
echo "[8/8] Creating git tag..."
|
||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
@@ -319,8 +309,8 @@ echo ""
|
||||
echo "Artifacts:"
|
||||
echo " - Version bumped in Cargo.toml and package.json"
|
||||
echo " - Changelog updated in CHANGELOG.md"
|
||||
echo " - Release manifest: releases/manifest.json"
|
||||
echo " - Release manifest copy: release-manifest.json"
|
||||
echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json"
|
||||
echo " - Live manifest: unchanged until assets pass publication verification"
|
||||
echo " - Staged artifacts: releases/v${VERSION}/"
|
||||
echo " - Git tag: v${VERSION}"
|
||||
echo ""
|
||||
|
||||
@@ -73,7 +73,6 @@ SINGLE = {
|
||||
"vaultwarden": "VAULTWARDEN_IMAGE",
|
||||
"nextcloud": "NEXTCLOUD_IMAGE",
|
||||
"searxng": "SEARXNG_IMAGE",
|
||||
"cryptpad": "CRYPTPAD_IMAGE",
|
||||
"filebrowser": "FILEBROWSER_IMAGE",
|
||||
"nginx-proxy-manager": "NPM_IMAGE",
|
||||
"portainer": "PORTAINER_IMAGE",
|
||||
@@ -81,9 +80,6 @@ SINGLE = {
|
||||
"fedimint": "FEDIMINT_IMAGE",
|
||||
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
|
||||
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
|
||||
"nostr-vpn": "NOSTR_VPN_IMAGE",
|
||||
"fips": "FIPS_IMAGE",
|
||||
"routstr": "ROUTSTR_IMAGE",
|
||||
"adguardhome": "ADGUARDHOME_IMAGE",
|
||||
}
|
||||
|
||||
@@ -100,13 +96,6 @@ STACK = {
|
||||
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
|
||||
"immich_redis": "REDIS_IMAGE",
|
||||
},
|
||||
"penpot": {
|
||||
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
|
||||
"penpot-backend": "PENPOT_BACKEND_IMAGE",
|
||||
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
|
||||
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
|
||||
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
|
||||
},
|
||||
"mempool": {
|
||||
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
|
||||
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
|
||||
|
||||
@@ -54,7 +54,6 @@ NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
|
||||
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
||||
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
|
||||
# Replaced by CryptPad (single Node.js process, e2e encrypted)
|
||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||
# 2.39.1 is what the fleet has actually been running via the moving :latest
|
||||
@@ -99,13 +98,9 @@ VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
|
||||
# Nostr
|
||||
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.10.0"
|
||||
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
|
||||
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
|
||||
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
|
||||
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
|
||||
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
|
||||
|
||||
# AI / Routing
|
||||
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
|
||||
|
||||
# Community / Gaming
|
||||
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.2.11"
|
||||
@@ -127,12 +122,6 @@ GITEA_IMAGE="docker.io/gitea/gitea:1.23"
|
||||
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
|
||||
IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
|
||||
|
||||
# Penpot stack
|
||||
PENPOT_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15"
|
||||
PENPOT_VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1"
|
||||
PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
|
||||
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
|
||||
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
|
||||
|
||||
# Custom UI containers (built from docker/ dirs, pushed to registry)
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user