Compare commits

..
Author SHA1 Message Date
Archipelago e16b389f04 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
286 changed files with 1051 additions and 12496 deletions
+1 -34
View File
@@ -1,40 +1,7 @@
# Changelog
## v1.8.4-alpha (2026-08-20)
## Unreleased
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
- **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.
- **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.
- **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.
- **A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.
- **Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)
- **A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.
## v1.8.3-alpha (2026-08-14)
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
## v1.8.2-alpha (2026-08-13)
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
- **The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)
- **New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.
## v1.8.1-alpha (2026-08-13)
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
- **The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.
- **New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets.
## v1.8.0-alpha (2026-08-12)
- **Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
@@ -93,11 +93,10 @@ describe('useAI', () => {
expect(activeModel.value).toBe('echo')
})
it('lists available providers with models, Routstr first', () => {
it('lists available providers with models', () => {
const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(4)
expect(availableProviders.value.length).toBe(3)
const ids = availableProviders.value.map(p => p.id)
expect(ids[0]).toBe('routstr')
expect(ids).toContain('claude')
expect(ids).toContain('openrouter')
expect(ids).toContain('mock')
@@ -119,7 +119,7 @@
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px] max-h-[70vh] overflow-y-auto"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle"
@click.stop
>
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
+5 -118
View File
@@ -13,14 +13,12 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
type Provider = 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter`
const ROUTSTR_MODELS_PATH = `${BASE}api/routstr/models`
const ROUTSTR_CHAT_PATH = `${BASE}api/routstr/chat/completions`
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -150,41 +148,8 @@ function looksLikeMissingApiKey(err: string): boolean {
)
}
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
// The node forwards the live Routstr aggregator's /v1/models; entries carry
// sats_pricing so completions are Cashu-paid against the operator's budget.
const routstrModels = ref<{ id: string; name: string }[]>([])
let routstrModelsFetched = false
async function refreshRoutstrModels() {
if (routstrModelsFetched) return
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
.filter((m: Record<string, unknown>) => typeof m.id === 'string')
.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
name: 'Routstr (sats)',
models: routstrModels.value.length > 0
? routstrModels.value
: [{ id: 'routstr-unavailable', name: 'No models — node offline?' }],
},
{
id: 'claude',
name: 'Claude (Max)',
@@ -416,71 +381,6 @@ async function streamOpenRouter(
}, onError, signal)
}
/**
* Routstr: one paid, NON-streaming, OpenAI-shaped completion through the
* node's session-gated `/aiui/api/routstr/` forwarder. The node quotes a
* price from the live catalog, pays with a Cashu token against the
* operator's budget (Settings → System → Routstr AI budget), redeems the
* change, and passes the provider's JSON back. The full answer is emitted
* as a single token — streaming across a paid hop is the planned follow-up.
*/
async function streamRoutstr(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const wireMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const res = await apiFetch(ROUTSTR_CHAT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
messages: wireMessages,
stream: false,
}),
signal,
})
const bodyText = await res.text().catch(() => '')
if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim.
let msg = `Routstr error ${res.status}`
try {
const parsed = JSON.parse(bodyText)
// Node refusals use {error:{message}}; the upstream provider nests
// its own as {detail:{error:{message}}} or a plain {detail:"..."}.
const detail = parsed?.detail
msg =
parsed?.error?.message ??
detail?.error?.message ??
(typeof detail === 'string' ? detail : undefined) ??
msg
} catch { /* keep the status-only message */ }
onError(msg)
return
}
if (signal?.aborted) return
try {
const parsed = JSON.parse(bodyText)
const text = parsed?.choices?.[0]?.message?.content
if (typeof text === 'string' && text.length > 0) {
onToken(text)
} else {
onError('Routstr returned an empty response')
}
} catch {
onError('Routstr returned a malformed response')
}
}
/**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live
@@ -719,11 +619,7 @@ export async function streamWithModel(
activeModel.value = model
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — the whole
// point of the picker entry is that it is a selection, not a fallback.
await streamRoutstr(history, onToken, onError, 'You are a helpful assistant.', signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -742,9 +638,8 @@ export async function streamWithModel(
export function useAI() {
const chatStore = useChatStore()
// Fetch Wavlake + Routstr catalogs on first use (non-blocking)
// Fetch Wavlake catalog on first use (non-blocking)
refreshWavlakeCatalog()
refreshRoutstrModels()
function stopGeneration() {
if (currentAbort) {
@@ -817,11 +712,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
@@ -937,11 +828,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore)
try {
if (provider === 'routstr') {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
+13 -49
View File
@@ -52,13 +52,13 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"version": "2.4.2",
"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",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -378,7 +378,7 @@
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -464,7 +464,7 @@
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -547,42 +547,6 @@
"/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"
}
]
}
-3
View File
@@ -2,9 +2,6 @@ app:
id: aiui
name: AI Assistant
version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
-81
View File
@@ -1,81 +0,0 @@
app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
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.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
pull_policy: if-not-present
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 8187
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/alby-hub
target: /data
options: [rw]
environment:
- WORK_DIR=/data
- PORT=8080
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
# advertised, so no extra port mapping is needed for payments to work.
- LOG_LEVEL=info
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 5
interfaces:
main:
name: Web UI
description: Alby Hub wallet interface
type: ui
port: 8187
protocol: http
metadata:
icon: /assets/img/app-icons/alby-hub.svg
repo: https://github.com/getAlby/hub
tier: optional
launch:
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
# proxied responses. Nodes older than the gate fix show a blocked
# frame — flip to true only if targeting such nodes.
open_in_new_tab: false
features:
- Self-custodial Lightning node (LDK) with a friendly wallet UI
- Connect wallets and apps via Nostr Wallet Connect (NWC)
- Per-app budgets and isolated sub-wallets
- Works with the Alby browser extension and mobile app
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack.
container:
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
pull_policy: if-not-present
network: archy-net
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.11
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
version: 2.6.0
description: BTCPay blockchain indexer service.
container:
image: source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.11
image: source.archipelago-foundation.org/lfg2025/nbxplorer:2.6.0
pull_policy: if-not-present
network: archy-net
secret_env:
-8
View File
@@ -2,14 +2,6 @@ app:
id: barkd
name: Ark Wallet
version: 0.3.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. bark ships on GitLab only
# (no GitHub mirror), so the gitlab fetcher is the one that can see it.
# NOTE: a version bump is code work, not a pin move — the REST shapes are
# coded in core/archipelago/src/wallet/ark_client.rs (see Dockerfile note).
upstream:
kind: gitlab
repo: ark-bitcoin/bark
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
container:
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
@@ -55,7 +49,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
@@ -61,7 +55,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
-3
View File
@@ -2,9 +2,6 @@ app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
-3
View File
@@ -2,9 +2,6 @@ app:
id: botfights
name: BotFights
version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
+3 -19
View File
@@ -1,17 +1,11 @@
app:
id: btcpay-server
name: BTCPay Server
version: 2.4.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
version: 2.4.2
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: docker.io/btcpayserver/btcpayserver:2.4.3
image: docker.io/btcpayserver/btcpayserver:2.4.2
pull_policy: if-not-present
network: archy-net
secret_env:
@@ -52,17 +46,7 @@ app:
container: 49392
protocol: tcp
bind: 127.0.0.1
# open, not gated: BTCPay has its own account system, and its public
# surfaces (checkout/invoice pages, payment buttons, webhooks) must be
# reachable by anonymous payers and machines — a dashboard login in
# front of a checkout link breaks the product. The gate still fronts
# the port; the operator can force the dashboard login back on from
# Settings → BTCPay Server → Access control.
auth: open
auth_rationale: >-
BTCPay enforces its own login for administration, and its checkout,
invoice and webhook endpoints are designed to be reached by
anonymous payers and payment processors.
auth: gated
volumes:
- type: bind
-6
View File
@@ -2,12 +2,6 @@ app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
-157
View File
@@ -1,157 +0,0 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
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
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
[rpc.restricted]
enable = true
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
-3
View File
@@ -2,9 +2,6 @@ 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:
-3
View File
@@ -2,9 +2,6 @@ app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
-6
View File
@@ -2,12 +2,6 @@ app:
id: electrumx
name: ElectrumX
version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
-6
View File
@@ -2,12 +2,6 @@ app:
id: filebrowser
name: File Browser
version: 2.27.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service.
container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
+1 -16
View File
@@ -2,12 +2,6 @@ app:
id: gitea
name: Gitea
version: "1.23"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
@@ -33,16 +27,7 @@ app:
container: 3000
protocol: tcp
bind: 127.0.0.1
# open, not gated: Gitea carries a complete login of its own, and git
# clients speak HTTP basic-auth — a cookie challenge in front of
# git-over-HTTP breaks every clone/push. The gate still fronts the
# port (iframe header fixes, retry page, Tor); the operator can force
# the dashboard login back on from Settings → Gitea → Access control.
auth: open
auth_rationale: >-
Gitea enforces its own account login on every page and API route;
git clients authenticate with basic-auth/tokens and cannot complete
a browser login challenge.
auth: gated
- host: 2222
container: 22
protocol: tcp
-6
View File
@@ -2,12 +2,6 @@ app:
id: grafana
name: Grafana
version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: homeassistant
name: Home Assistant
version: 2026.8.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
version: 2026.7.3
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich-postgres
name: Immich Postgres
version: "14-vectorchord0.4.3-pgvectors0.2.0"
# Upstream is the Immich-built Postgres image, published only on ghcr.io
# (no GitHub release tags, no Docker Hub repo) — the ghcr fetcher in
# scripts/check-upstream-releases.py is the only one that can see it.
upstream:
kind: ghcr
repo: immich-app/postgres
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
# Container named immich_postgres (underscore) to match the runtime's existing
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich
name: Immich
version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-minio
name: IndeedHub MinIO
version: "RELEASE.2024-11-07T00-52-20Z"
# MinIO's release tags are date-opaque (RELEASE.YYYY-MM-DD…), so the
# checker reports them as UNCOMPARABLE rather than ordering them — the
# latest tag is still shown for hand comparison, which is the point.
upstream:
kind: github
repo: minio/minio
description: MinIO S3-compatible object storage for IndeedHub media.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub.
category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub.
category: community
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-relay
name: IndeedHub Nostr Relay
version: "0.9.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community
@@ -17,7 +11,7 @@ app:
container_name: indeedhub-relay
container:
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [relay]
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub
name: IndeeHub
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: jellyfin
name: Jellyfin
version: 10.8.13
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: jellyfin/jellyfin
description: Free media server. Stream movies, music, and photos.
container:
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present
network: pasta
-6
View File
@@ -2,12 +2,6 @@ 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:
-3
View File
@@ -2,9 +2,6 @@ app:
id: lnd-ui
name: LND UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
-6
View File
@@ -2,12 +2,6 @@ app:
id: lnd
name: LND
version: 0.18.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: lightningnetwork/lnd
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool-api
name: Mempool API
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Backend API for mempool explorer.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool
name: Mempool Explorer
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image_signature: cosign://...
pull_policy: if-not-present
-3
View File
@@ -2,9 +2,6 @@ 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:
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-dashboard
name: NetBird Dashboard
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/dashboard
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-server
name: NetBird Server
version: "0.71.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/netbird
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: netbird
name: NetBird
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking
@@ -18,7 +12,7 @@ app:
container_name: netbird
container:
image: docker.io/library/nginx:1.31.4-alpine
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a
-6
View File
@@ -2,12 +2,6 @@ app:
id: nextcloud
name: Nextcloud
version: "29"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: nextcloud/server
description: Your own private cloud. File sync, calendars, contacts.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
version: 0.8.0
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:0.10.0
image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
-81
View File
@@ -1,81 +0,0 @@
app:
id: phoenixd
name: phoenixd
version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ACINQ/phoenixd
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.
category: money
container:
# Image entrypoint already runs with --agree-to-terms-of-service and
# --http-bind-ip 0.0.0.0, as user "phoenix"; no custom args needed.
image: source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0
pull_policy: if-not-present
# The image runs as user phoenix (1000:1000); the datadir bind source
# must be chowned to that identity or phoenixd dies on
# "Failed to open /data/phoenix.conf with Permission denied".
data_uid: "1000:1000"
dependencies:
- storage: 500Mi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 9740
container: 9740
protocol: tcp
bind: 127.0.0.1
auth: none
auth_rationale: >-
Loopback-only JSON API, not a web page. Every request is
authenticated by the http password phoenixd generates in its own
data directory on first run; the app gate's browser login page
would break the API clients this port exists for.
volumes:
# The wallet seed (seed.dat) and phoenix.conf live here. This directory
# must survive reinstall/migration like any other app data dir —
# losing it means losing funds.
# Target is /data (via PHOENIX_DATADIR below), NOT the image's default
# /phoenix/.phoenix: the orchestrator treats any bind path containing a
# dot as a file mount and skips creating its source directory, so a
# hidden-dir target never gets its host dir and the unit crash-loops.
- type: bind
source: /var/lib/archipelago/phoenixd
target: /data
options: [rw]
environment:
- PHOENIX_DATADIR=/data
health_check:
type: tcp
endpoint: localhost:9740
interval: 30s
timeout: 5s
retries: 5
metadata:
icon: /assets/img/app-icons/phoenixd.svg
repo: https://github.com/ACINQ/phoenixd
tier: optional
features:
- Ultra-light Lightning node — no bitcoin node required
- Automated channel and liquidity management (fees apply)
- Simple HTTP API + websockets for payments
- Backed by the team behind the Phoenix mobile wallet
-6
View File
@@ -2,12 +2,6 @@ app:
id: photoprism
name: PhotoPrism
version: "240915"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: photoprism/photoprism
description: AI-powered photo management with facial recognition.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-openwakeword
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
+2 -8
View File
@@ -1,13 +1,7 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.4.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-piper
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
@@ -18,7 +12,7 @@ app:
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.4.2
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
-8
View File
@@ -6,14 +6,6 @@ app:
# pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2"
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
# project's GitHub tags are not the image tags). NOTE: this manifest
# deliberately ships an args-tuned revision AHEAD of the image tag (see
# comment above) — BEHIND here means the image tag moved and the tuned
# revision needs re-basing onto it, not just a pin bump.
upstream:
kind: dockerhub
repo: rhasspy/wyoming-whisper
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: pine
name: Pine
version: "1.3.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
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.
category: home
@@ -19,7 +13,7 @@ app:
container_name: pine
container:
image: docker.io/library/nginx:1.31.4-alpine
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: portainer
name: Portainer
version: 2.19.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: portainer/portainer
description: Container management web UI for the local Podman socket.
category: development
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
-3
View File
@@ -2,9 +2,6 @@ app:
id: router
name: Mesh Router
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: searxng
name: SearXNG
version: 1.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: searxng/searxng
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: strfry
name: Strfry Nostr Relay
version: 1.1.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: hoytech/strfry
version: 0.9.0
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container:
image: dockurr/strfry:1.1.2
image: dockurr/strfry:1.0.4
image_signature: cosign://...
pull_policy: verify-signature
-6
View File
@@ -2,12 +2,6 @@ app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: louislam/uptime-kuma
description: Self-hosted uptime monitoring.
container:
+2 -8
View File
@@ -1,17 +1,11 @@
app:
id: vaultwarden
name: Vaultwarden
version: 1.37.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dani-garcia/vaultwarden
version: 1.30.0
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
pull_policy: if-not-present
network: pasta
+58 -398
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.7.129-alpha"
dependencies = [
"anyhow",
"archipelago-container",
@@ -120,7 +120,6 @@ dependencies = [
"blake3",
"bs58",
"bytes",
"cashu",
"chacha20poly1305",
"chrono",
"ciborium",
@@ -187,7 +186,7 @@ dependencies = [
"futures",
"hex",
"hyper 0.14.32",
"indexmap 2.13.0",
"indexmap",
"log",
"reqwest 0.11.27",
"serde",
@@ -451,8 +450,8 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
dependencies = [
"bitcoin-internals",
"bitcoin_hashes",
"bitcoin-internals 0.3.0",
"bitcoin_hashes 0.14.1",
]
[[package]]
@@ -500,11 +499,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
[[package]]
name = "bip39"
version = "2.2.2"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.13.0",
"rand 0.8.5",
"rand_core 0.6.4",
"serde",
@@ -527,26 +526,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
dependencies = [
"base58ck",
"base64 0.21.7",
"bech32",
"bitcoin-internals",
"bitcoin-internals 0.3.0",
"bitcoin-io",
"bitcoin-units",
"bitcoin_hashes",
"hex-conservative",
"bitcoin_hashes 0.14.1",
"hex-conservative 0.2.2",
"hex_lit",
"secp256k1",
"serde",
]
[[package]]
name = "bitcoin-internals"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
[[package]]
name = "bitcoin-internals"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
dependencies = [
"serde",
]
[[package]]
name = "bitcoin-io"
@@ -560,8 +560,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
dependencies = [
"bitcoin-internals",
"serde",
"bitcoin-internals 0.3.0",
]
[[package]]
name = "bitcoin_hashes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [
"bitcoin-internals 0.2.0",
"hex-conservative 0.1.2",
]
[[package]]
@@ -571,7 +580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
"hex-conservative",
"hex-conservative 0.2.2",
"serde",
]
@@ -698,32 +707,6 @@ dependencies = [
"serde",
]
[[package]]
name = "cashu"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bd7216af2b980e203d10677076d8c6c5c30610cbdea6549b8a9020cfa0cf47b"
dependencies = [
"bitcoin",
"cbor-diag",
"ciborium",
"lightning",
"lightning-invoice",
"once_cell",
"serde",
"serde_json",
"serde_with",
"strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.18",
"tracing",
"unicode-normalization",
"url",
"uuid",
"web-time",
"zeroize",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -733,25 +716,6 @@ dependencies = [
"cipher",
]
[[package]]
name = "cbor-diag"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
dependencies = [
"bs58",
"chrono",
"data-encoding",
"half",
"nom",
"num-bigint",
"num-rational",
"num-traits",
"separator",
"url",
"uuid",
]
[[package]]
name = "cc"
version = "1.2.54"
@@ -1128,18 +1092,8 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
"darling_core",
"darling_macro",
]
[[package]]
@@ -1156,37 +1110,13 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"darling_core",
"quote",
"syn 2.0.114",
]
@@ -1217,37 +1147,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1288,9 +1187,6 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
@@ -1318,7 +1214,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -1418,18 +1314,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "dnssec-prover"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869bf72abc8c654b350aa8d881c5d9957b85e1e1ed6569c10cd68e9f505d5435"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -1890,7 +1774,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@@ -1909,7 +1793,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@@ -1945,12 +1829,6 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2009,6 +1887,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
[[package]]
name = "hex-conservative"
version = "0.2.2"
@@ -2507,17 +2391,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -2634,7 +2507,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"smallvec",
"strum 0.28.0",
"strum",
"time",
"tokio",
"tokio-stream",
@@ -2722,7 +2595,7 @@ dependencies = [
"rand 0.10.1",
"rustls 0.23.36",
"simple-dns",
"strum 0.28.0",
"strum",
"tokio",
"tracing",
"url",
@@ -2802,7 +2675,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_bytes",
"strum 0.28.0",
"strum",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
@@ -2892,59 +2765,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -3091,55 +2911,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "lightning"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6"
dependencies = [
"bech32",
"bitcoin",
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
"lightning-invoice",
"lightning-macros",
"lightning-types",
"possiblyrandom",
]
[[package]]
name = "lightning-invoice"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444"
dependencies = [
"bech32",
"bitcoin",
"lightning-types",
"serde",
]
[[package]]
name = "lightning-macros"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "lightning-types"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6"
dependencies = [
"bitcoin",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -3644,7 +3415,7 @@ dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
"bitcoin_hashes 0.14.1",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305",
@@ -3746,17 +3517,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4136,7 +3896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"indexmap",
"quick-xml",
"serde",
"time",
@@ -4174,15 +3934,6 @@ dependencies = [
"serde",
]
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "portmapper"
version = "0.19.0"
@@ -4222,15 +3973,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "possiblyrandom"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "postcard"
version = "1.1.3"
@@ -4901,30 +4643,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4974,7 +4692,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.14.1",
"rand 0.8.5",
"secp256k1-sys",
"serde",
@@ -5040,12 +4758,6 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "separator"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
[[package]]
name = "serde"
version = "1.0.228"
@@ -5130,46 +4842,13 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"itoa",
"ryu",
"serde",
@@ -5458,31 +5137,13 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.114",
"strum_macros",
]
[[package]]
@@ -5945,7 +5606,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
@@ -5959,7 +5620,7 @@ version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
@@ -6162,9 +5823,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
@@ -6242,7 +5903,6 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -6420,7 +6080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"indexmap",
"wasm-encoder",
"wasmparser",
]
@@ -6459,7 +6119,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.13.0",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap",
"semver",
]
@@ -7018,7 +6678,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.13.0",
"indexmap",
"prettyplease",
"syn 2.0.114",
"wasm-metadata",
@@ -7049,7 +6709,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.13.0",
"indexmap 2.13.0",
"indexmap",
"log",
"serde",
"serde_derive",
@@ -7068,7 +6728,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"indexmap",
"log",
"semver",
"serde",
@@ -7299,7 +6959,7 @@ dependencies = [
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.7.129-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -72,7 +72,7 @@ bs58 = "0.5"
chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] }
bip39 = { version = "=2.1.0", features = ["rand"] }
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration
@@ -143,7 +143,6 @@ async-trait = "0.1"
iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true }
lofty = "0.24.0"
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
[dev-dependencies]
tokio-test = "0.4"
-429
View File
@@ -1,429 +0,0 @@
//! CDP input bridge — forwards companion remote input into the local kiosk
//! Chromium as *trusted*, browser-level input via the DevTools protocol.
//!
//! Why this exists: the web relay path (`remote-relay.ts`) synthesizes DOM
//! events in the top document, and synthetic events can never cross into a
//! cross-origin iframe — so on the kiosk, companion taps/keys/scrolls died at
//! the border of every containerized app's frame. CDP `Input.dispatch*`
//! events enter the browser's real input pipeline: they hit-test through any
//! frame, move focus, and insert text exactly like a physical device, which
//! is the only correct way to drive app iframes (tracked in the unified task
//! tracker; supersedes the earlier "no CDP" note in
//! `docs/tv-input-iframe-apps.md`, which was about gamepad *keys* only).
//!
//! The kiosk launcher opens Chromium with `--remote-debugging-port=9222`
//! bound to loopback. This keeper task discovers the page target, holds one
//! WebSocket to it, and reconnects whenever the kiosk restarts (the launcher
//! supervises Chromium in a loop, so the debugger URL changes under us).
//! When the bridge is not connected (non-kiosk installs, kiosk booting),
//! `is_active()` is false and callers fall back to the web relay unchanged.
//!
//! Security: the CDP port is loopback-only and Chromium's default origin
//! check stands (we deliberately do NOT pass `--remote-allow-origins`, so
//! browser pages can't open the debug socket; our raw client sends no
//! Origin header and is accepted).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display.
const KIOSK_UNIT: &str = "/etc/systemd/system/archipelago-kiosk.service";
/// One relay scroll step ≈ this many CSS pixels (matches remote-relay.ts).
const SCROLL_STEP_PX: f64 = 100.0;
/// Cloneable handle the WS handlers use to feed validated relay JSON into
/// the keeper task.
#[derive(Clone)]
pub struct CdpBridge {
tx: mpsc::Sender<String>,
connected: Arc<AtomicBool>,
}
impl CdpBridge {
/// Spawn the session keeper and return the shared handle.
pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel::<String>(256);
let connected = Arc::new(AtomicBool::new(false));
tokio::spawn(run_keeper(rx, connected.clone()));
Self { tx, connected }
}
/// True only while a live CDP session to the kiosk Chromium exists.
pub fn is_active(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
/// Queue a validated relay input message (the exact JSON that goes to the
/// broadcast channel) for CDP dispatch. Best-effort: if the keeper is
/// behind or gone the message is dropped — input is transient by nature.
pub fn send(&self, relay_json: &str) {
let _ = self.tx.try_send(relay_json.to_string());
}
}
/// Virtual cursor state, server-side. The web relay keeps this in the kiosk
/// page (`cursorX`/`cursorY`); CDP needs its own copy because trusted mouse
/// events carry absolute viewport coordinates.
struct Cursor {
x: f64,
y: f64,
w: f64,
h: f64,
}
async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>) {
loop {
// Cheap gate: no kiosk unit on this node → nothing to drive. Keep
// draining queued input so the channel never backs up.
if tokio::fs::metadata(KIOSK_UNIT).await.is_err() {
drain_for(&mut rx, Duration::from_secs(60)).await;
continue;
}
let Some(ws_url) = discover_page_target().await else {
// Kiosk configured but Chromium not up (or CDP flag not rolled
// out yet) — retry gently.
drain_for(&mut rx, Duration::from_secs(15)).await;
continue;
};
match drive_session(&ws_url, &mut rx, &connected).await {
Ok(()) => info!("CDP kiosk input session ended cleanly"),
Err(e) => debug!(error = %e, "CDP kiosk input session dropped — will re-discover"),
}
connected.store(false, Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
/// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
}
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
/// localhost (the kiosk app) over e.g. devtools/extension targets.
async fn discover_page_target() -> Option<String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.ok()?;
let list: Vec<Value> = client
.get(format!("{CDP_HTTP}/json/list"))
.send()
.await
.ok()?
.json()
.await
.ok()?;
let pages: Vec<&Value> = list
.iter()
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.collect();
let preferred = pages
.iter()
.find(|t| {
t.get("url")
.and_then(Value::as_str)
.is_some_and(|u| u.contains("localhost") || u.contains("127.0.0.1"))
})
.or_else(|| pages.first());
preferred?
.get("webSocketDebuggerUrl")
.and_then(Value::as_str)
.map(str::to_string)
}
async fn drive_session(
ws_url: &str,
rx: &mut mpsc::Receiver<String>,
connected: &Arc<AtomicBool>,
) -> anyhow::Result<()> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
let (mut sink, mut stream) = ws.split();
let mut next_id: u64 = 0;
let mut id = move || {
next_id += 1;
next_id
};
// Viewport size for cursor clamping. Best-effort: fall back to 1080p if
// the metrics call fails — clamping is a nicety, not a correctness need.
sink.send(Message::Text(
json!({"id": id(), "method": "Page.getLayoutMetrics"}).to_string(),
))
.await?;
let (mut vw, mut vh) = (1920.0_f64, 1080.0_f64);
if let Ok(Some(Ok(Message::Text(txt)))) =
tokio::time::timeout(Duration::from_secs(3), stream.next()).await
{
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
if let Some(vp) = v.pointer("/result/cssLayoutViewport") {
vw = vp.get("clientWidth").and_then(Value::as_f64).unwrap_or(vw);
vh = vp.get("clientHeight").and_then(Value::as_f64).unwrap_or(vh);
}
}
}
let mut cursor = Cursor {
x: vw / 2.0,
y: vh / 2.0,
w: vw,
h: vh,
};
connected.store(true, Ordering::Relaxed);
info!(viewport = %format!("{vw}x{vh}"), "CDP kiosk input bridge connected");
loop {
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { return Ok(()) };
for frame in translate(&cmd, &mut cursor, &mut id) {
sink.send(Message::Text(frame.to_string())).await?;
}
}
msg = stream.next() => {
match msg {
// Responses/events — nothing to correlate, but a read
// error or close means Chromium restarted.
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => anyhow::bail!("CDP socket closed"),
}
}
}
}
}
/// Translate one validated relay input message into CDP command frames.
fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let Ok(msg) = serde_json::from_str::<Value>(raw) else {
return vec![];
};
match msg.get("t").and_then(Value::as_str) {
Some("m") => {
let dx = msg.get("x").and_then(Value::as_i64).unwrap_or(0) as f64;
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64;
cursor.x = (cursor.x + dx).clamp(0.0, cursor.w - 1.0);
cursor.y = (cursor.y + dy).clamp(0.0, cursor.h - 1.0);
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
}
Some("c") => {
let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b {
2 => ("middle", 4),
3 => ("right", 2),
_ => ("left", 1),
};
vec![
// Hover first so the press lands on current hit-test state.
mouse_event(id(), "mouseMoved", cursor, "none", 0, 1),
mouse_event(id(), "mousePressed", cursor, button, buttons, 1),
mouse_event(id(), "mouseReleased", cursor, button, 0, 1),
]
}
Some("s") => {
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64 * SCROLL_STEP_PX;
vec![json!({
"id": id(),
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mouseWheel",
"x": cursor.x, "y": cursor.y,
"deltaX": 0.0, "deltaY": dy,
"pointerType": "mouse",
}
})]
}
Some("k") => {
let Some(k) = msg.get("k").and_then(Value::as_str) else {
return vec![];
};
key_events(k, id)
}
_ => vec![],
}
}
fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({
"id": id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": kind,
"x": cursor.x, "y": cursor.y,
"button": button,
"buttons": buttons,
"clickCount": if kind == "mousePressed" || kind == "mouseReleased" { clicks } else { 0 },
"pointerType": "mouse",
}
})
}
/// xdotool named key → (DOM key, DOM code, Windows virtual-key code).
fn named_key(k: &str) -> Option<(&'static str, &'static str, i32)> {
Some(match k {
"Return" => ("Enter", "Enter", 13),
"BackSpace" => ("Backspace", "Backspace", 8),
"Escape" => ("Escape", "Escape", 27),
"Tab" => ("Tab", "Tab", 9),
"Delete" => ("Delete", "Delete", 46),
"Up" => ("ArrowUp", "ArrowUp", 38),
"Down" => ("ArrowDown", "ArrowDown", 40),
"Left" => ("ArrowLeft", "ArrowLeft", 37),
"Right" => ("ArrowRight", "ArrowRight", 39),
"Home" => ("Home", "Home", 36),
"End" => ("End", "End", 35),
"Prior" => ("PageUp", "PageUp", 33),
"Next" => ("PageDown", "PageDown", 34),
"F1" => ("F1", "F1", 112),
"F2" => ("F2", "F2", 113),
"F3" => ("F3", "F3", 114),
"F4" => ("F4", "F4", 115),
"F5" => ("F5", "F5", 116),
"F6" => ("F6", "F6", 117),
"F7" => ("F7", "F7", 118),
"F8" => ("F8", "F8", 119),
"F9" => ("F9", "F9", 120),
"F10" => ("F10", "F10", 121),
"F11" => ("F11", "F11", 122),
"F12" => ("F12", "F12", 123),
_ => return None,
})
}
/// xdotool symbol name → printable char (the relay whitelist speaks xdotool).
fn symbol_char(k: &str) -> Option<char> {
Some(match k {
"space" => ' ',
"exclam" => '!',
"at" => '@',
"numbersign" => '#',
"dollar" => '$',
"percent" => '%',
"asciicircum" => '^',
"ampersand" => '&',
"asterisk" => '*',
"parenleft" => '(',
"parenright" => ')',
"underscore" => '_',
"plus" => '+',
"braceleft" => '{',
"braceright" => '}',
"bar" => '|',
"colon" => ':',
"quotedbl" => '"',
"less" => '<',
"greater" => '>',
"question" => '?',
"asciitilde" => '~',
"minus" => '-',
"equal" => '=',
"bracketleft" => '[',
"bracketright" => ']',
"backslash" => '\\',
"semicolon" => ';',
"apostrophe" => '\'',
"grave" => '`',
"comma" => ',',
"period" => '.',
"slash" => '/',
_ => return None,
})
}
/// Build the CDP frame pair (keyDown, keyUp) for one relay key name,
/// including `modifier+base` combos. A keyDown that carries `text` both
/// fires real keydown/keypress AND inserts the character — exactly how a
/// physical keystroke behaves, so games see the key and fields get the text.
fn key_events(k: &str, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let (modifiers, base) = match k.split_once('+') {
Some((m, b)) => (
match m {
"alt" => 1,
"ctrl" => 2,
"super" => 4,
"shift" => 8,
_ => 0,
},
b,
),
None => (0, k),
};
let (key, code, vk, text): (String, Option<&str>, i32, Option<String>) =
if let Some((key, code, vk)) = named_key(base) {
// Enter carries "\r" like a real keyboard so single-line inputs
// submit and textareas newline.
let text = (key == "Enter").then(|| "\r".to_string());
(key.to_string(), Some(code), vk, text)
} else {
let ch = if base.chars().count() == 1 {
base.chars().next()
} else {
symbol_char(base)
};
let Some(mut ch) = ch else {
return vec![];
};
if modifiers == 8 && ch.is_ascii_alphabetic() {
ch = ch.to_ascii_uppercase();
}
let vk = ch.to_ascii_uppercase() as i32;
// Ctrl/Alt/Super chords are shortcuts, not typing — no text.
let text = (modifiers & !8 == 0).then(|| ch.to_string());
(ch.to_string(), None, vk, text)
};
let mut down = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyDown",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
down["params"]["code"] = json!(code);
}
if let Some(t) = &text {
down["params"]["text"] = json!(t);
down["params"]["unmodifiedText"] = json!(t);
}
let mut up = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyUp",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
up["params"]["code"] = json!(code);
}
vec![down, up]
}
-18
View File
@@ -1,5 +1,4 @@
mod blob;
mod cdp;
mod content;
mod dwn;
mod model_proxy;
@@ -7,7 +6,6 @@ mod node_message;
mod proxy;
mod remote_input;
mod remote_relay;
mod routstr_proxy;
mod websocket;
use crate::api::rpc::RpcHandler;
@@ -52,10 +50,6 @@ pub struct ApiHandler {
/// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>,
/// Bridge that dispatches companion input into the local kiosk Chromium
/// as trusted CDP events (reaches inside cross-origin app iframes).
/// Inert (never connects) on nodes without a kiosk.
cdp_bridge: cdp::CdpBridge,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@@ -84,7 +78,6 @@ impl ApiHandler {
);
let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16);
let cdp_bridge = cdp::CdpBridge::spawn();
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
@@ -115,7 +108,6 @@ impl ApiHandler {
session_store,
input_relay_tx,
external_open_tx,
cdp_bridge,
blob_store,
self_pubkey_hex,
})
@@ -409,7 +401,6 @@ impl ApiHandler {
req,
self.input_relay_tx.clone(),
self.external_open_tx.subscribe(),
self.cdp_bridge.clone(),
)
.await;
}
@@ -424,7 +415,6 @@ impl ApiHandler {
req,
self.input_relay_tx.subscribe(),
self.external_open_tx.clone(),
self.cdp_bridge.clone(),
)
.await;
}
@@ -459,14 +449,6 @@ impl ApiHandler {
self.handle_model_proxy(req_with_bytes, p).await
}
// AIUI Routstr proxy — the explicit, user-selected Routstr
// provider (model catalog + Cashu-paid completions), same
// session-gate discipline as the model proxy above. D-05: paid
// requests are refused unless the operator has armed a budget.
(_, p) if p.starts_with("/aiui/api/routstr/") => {
self.handle_routstr_proxy(req_with_bytes, p).await
}
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
@@ -84,14 +84,14 @@ async fn route_model_proxy(
/// call back into `ApiHandler::is_authenticated` — keeping this small and
/// dependency-free is what makes the 401 behaviour unit-testable without
/// paying for a full `ApiHandler` in every test.
pub(super) async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => session_store.validate(&token).await,
None => false,
}
}
pub(super) fn unauthorized() -> Response<Body> {
fn unauthorized() -> Response<Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
Response::builder()
.status(StatusCode::UNAUTHORIZED)
@@ -119,7 +119,7 @@ fn key_not_configured() -> Response<Body> {
/// backends' egress screen never sees the forwarder path — the standalone
/// frontend posts FULL history and images straight here — so the forwarder
/// screens for itself. 400, plain-language, never naming what matched.
pub(super) fn blocked_secret_shaped() -> Response<Body> {
fn blocked_secret_shaped() -> Response<Body> {
let body = serde_json::json!({
"error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
});
@@ -134,12 +134,12 @@ pub(super) fn blocked_secret_shaped() -> Response<Body> {
/// the assistant's secret-shape rules (G-B1) with this node's own secrets
/// as the deny corpus. Returns Some(kind) — kind only, never the value —
/// when the content must not leave.
pub(super) async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
let secrets = crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
crate::assistant::egress::scan_secret_shapes(text, &secrets)
}
pub(super) fn bad_gateway(msg: &str) -> Response<Body> {
fn bad_gateway(msg: &str) -> Response<Body> {
let body = serde_json::json!({ "error": msg });
Response::builder()
.status(StatusCode::BAD_GATEWAY)
@@ -331,7 +331,7 @@ async fn forward(
/// same shape as `proxy.rs`'s peer-content Range streamer — so a
/// token-by-token reply doesn't wait for the full response before the first
/// byte reaches the browser.
pub(super) fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let mut builder = Response::builder().status(status);
@@ -212,7 +212,6 @@ impl ApiHandler {
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
@@ -318,20 +317,9 @@ impl ApiHandler {
} else {
text.clone()
};
let validation = handle_input(&text).await;
let _ = relay_tx.send(relay_text.clone());
// Trusted-input path: while the kiosk CDP
// bridge is live, also dispatch validated
// input into the kiosk Chromium so it
// lands inside cross-origin app iframes
// (the web relay above can't cross that
// boundary; the kiosk subscriber mutes its
// own DOM synthesis — remote_relay.rs).
if matches!(validation, Ok(None)) && cdp_bridge.is_active() {
cdp_bridge.send(&relay_text);
}
let _ = relay_tx.send(relay_text);
match validation {
match handle_input(&text).await {
Ok(Some(reply)) => {
let _ = tx.send(Message::Text(reply)).await;
}
@@ -21,16 +21,7 @@ impl ApiHandler {
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> {
// The kiosk browser self-identifies with ?kiosk=1 so we can suppress
// its DOM-synthesis path while the CDP bridge delivers trusted input
// (otherwise every key/click/scroll would apply twice). A remote
// browser claiming kiosk=1 only mutes its own input — harmless.
let is_kiosk = req
.uri()
.query()
.is_some_and(|q| q.split('&').any(|s| s == "kiosk=1"));
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -69,19 +60,6 @@ impl ApiHandler {
msg = relay_rx.recv() => {
match msg {
Ok(text) => {
// Kiosk + live CDP bridge: keys/clicks/
// scrolls arrive as trusted browser input
// via CDP; forward only cursor moves (the
// on-screen cursor is drawn by the page)
// so nothing applies twice.
if is_kiosk && cdp_bridge.is_active() {
let tag = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("t").and_then(|t| t.as_str().map(str::to_string)));
if !matches!(tag.as_deref(), Some("m") | Some("o") | Some("p")) {
continue;
}
}
if tx.send(Message::Text(text)).await.is_err() {
break;
}
@@ -1,535 +0,0 @@
//! Session-gated forwarder for `/aiui/api/routstr/*` — the explicit,
//! user-selected Routstr path (as opposed to `assistant/backends/routstr.rs`,
//! which is the D-04 fallback leg the operator never chooses directly).
//!
//! AIUI's model picker lists Routstr as a first-class provider; selecting one
//! of its models routes chat completions through here. Same discipline as
//! `model_proxy.rs`: auth is re-derived from the request's own session cookie
//! (never trusted to nginx), inbound `authorization`/`cookie` headers are
//! never forwarded, and every outbound body is egress-screened (S3) before it
//! leaves the node.
//!
//! Payment is Cashu, D-05-gated end to end: a request is refused unless the
//! operator has set a non-zero Routstr allowance (Settings → System), the
//! quoted price fits the remaining allowance, and `auto_pay_token` (the ONE
//! budget-capped payment primitive, T-13-89) agrees to build the token. The
//! provider's change (`X-Cashu` / `X-Cashu-Refund` response headers, per
//! docs.routstr.com) is redeemed back into the node wallet and only the net
//! is recorded against the allowance.
//!
//! Upstream is the public Routstr aggregator instance routstr.com itself
//! ships against (verified live 2026-08-14: `/v1/models` serves the full
//! catalog with `sats_pricing`; the canonical `api.routstr.com` host 404s).
//! Making the instance operator-configurable — or sourcing it from the Nostr
//! provider announcements once those carry real endpoint/pricing content —
//! is the planned follow-up, not this file's job.
use super::ApiHandler;
use crate::session::SessionStore;
use anyhow::Result;
use hyper::{Body, Method, Request, Response, StatusCode};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use super::model_proxy::{
bad_gateway, blocked_secret_shaped, forward_screen, is_authenticated, unauthorized,
};
/// The live public Routstr aggregator (the same instance routstr.com's own
/// frontend queries for `/v1/providers` and `/v1/models`).
const ROUTSTR_INSTANCE: &str = "https://routstr.otrta.me";
/// Generation cap forced onto every forwarded completion — never unbounded
/// (T-13-88), and the completion half of the price quote is arithmetic over
/// exactly this figure.
const MAX_COMPLETION_TOKENS: u64 = 1024;
/// Same round-trip ceiling as `model_proxy.rs`'s Claude/Ollama forwarders.
const FORWARD_TIMEOUT_SECS: u64 = 180;
/// Models-catalog cache TTL — mirrors `backends/routstr.rs`'s provider
/// discovery TTL. The catalog prices every chat request, so it cannot be
/// fetched per-message without doubling latency.
const MODELS_CACHE_TTL: Duration = Duration::from_secs(300);
/// One model's sats-denominated pricing, parsed from the aggregator's
/// `/v1/models` entries (`sats_pricing`). Rates are sats PER TOKEN (fractional
/// floats); `request` is a flat per-request fee in sats. Untrusted input — a
/// missing/garbled field parses as 0.0 and simply prices low, which the
/// remaining-allowance ceiling still caps.
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct SatsPricing {
#[serde(default)]
prompt: f64,
#[serde(default)]
completion: f64,
#[serde(default)]
request: f64,
}
/// Quote a price in whole sats for one completion call: flat request fee +
/// prompt rate × (payload chars / 4, the usual chars-per-token rule of thumb)
/// + completion rate × the forced `MAX_COMPLETION_TOKENS` cap, +20% margin,
/// rounded up, never below 1. Deliberately a pure function so the arithmetic
/// is unit-testable; deliberately conservative because the provider's change
/// comes back as a Cashu refund and is redeemed — overquoting costs nothing
/// but float, underquoting gets the request rejected upstream.
fn estimate_price_sats(pricing: &SatsPricing, prompt_chars: usize, completion_tokens: u64) -> u64 {
let prompt_tokens = (prompt_chars as f64) / 4.0;
let raw = pricing.request
+ pricing.prompt * prompt_tokens
+ pricing.completion * (completion_tokens as f64);
let with_margin = raw * 1.2;
(with_margin.ceil() as u64).max(1)
}
/// Process-lifetime cache of the aggregator's models catalog (same pattern as
/// `backends/routstr.rs`'s `PROVIDER_CACHE`).
static MODELS_CACHE: OnceLock<StdMutex<Option<(Instant, Value)>>> = OnceLock::new();
fn cached_models() -> Option<Value> {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
let guard = cache.lock().expect("routstr models cache poisoned");
guard.as_ref().and_then(|(at, models)| {
if at.elapsed() < MODELS_CACHE_TTL {
Some(models.clone())
} else {
None
}
})
}
fn set_cached_models(models: Value) {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
*cache.lock().expect("routstr models cache poisoned") = Some((Instant::now(), models));
}
/// Fetch (or serve cached) the aggregator's `/v1/models` catalog.
async fn fetch_models() -> Result<Value> {
if let Some(cached) = cached_models() {
return Ok(cached);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/models");
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("routstr models upstream returned HTTP {}", resp.status());
}
let models: Value = resp.json().await?;
set_cached_models(models.clone());
Ok(models)
}
/// Find one model's `sats_pricing` in the catalog by exact id.
fn pricing_for_model(models: &Value, model_id: &str) -> Option<SatsPricing> {
models
.get("data")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(|v| v.as_str()) == Some(model_id))
.and_then(|m| m.get("sats_pricing"))
.and_then(|sp| serde_json::from_value(sp.clone()).ok())
}
fn json_response(status: StatusCode, body: Value) -> Response<Body> {
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
.unwrap_or_else(|_| Response::new(Body::from("{}")))
}
/// Plain-language refusal naming the UI path that fixes it — never a bare
/// status (RULE: every action needs a UI path).
fn budget_refusal(msg: String) -> Response<Body> {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({ "error": { "message": msg } }),
)
}
impl ApiHandler {
/// Entry point wired into the `/aiui/api/routstr/` arm in `mod.rs` —
/// thin, like `handle_model_proxy`, so the routing/budget logic below is
/// testable without a full `ApiHandler`.
pub(super) async fn handle_routstr_proxy(
&self,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
route_routstr_proxy(&self.session_store, &self.config.data_dir, req, path).await
}
}
async fn route_routstr_proxy(
session_store: &SessionStore,
data_dir: &Path,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
if !is_authenticated(session_store, req.headers()).await {
tracing::warn!("401 routstr proxy {} — session invalid or missing", path);
return Ok(unauthorized());
}
match path.strip_prefix("/aiui/api/routstr/") {
Some("models") if req.method() == Method::GET => forward_models().await,
Some("chat/completions") if req.method() == Method::POST => {
forward_chat(req, data_dir).await
}
_ => Ok(unauthorized()),
}
}
/// GET /aiui/api/routstr/models — the full catalog, passed through so AIUI
/// can render ids/names and show sats pricing. Read-only and unpaid.
async fn forward_models() -> Result<Response<Body>> {
match fetch_models().await {
Ok(models) => Ok(json_response(StatusCode::OK, models)),
Err(e) => {
tracing::warn!("routstr proxy: models upstream failed: {}", e);
Ok(bad_gateway("Routstr model catalog is unreachable"))
}
}
}
/// POST /aiui/api/routstr/chat/completions — one paid, non-streaming,
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
/// offline) → price quote → pay → forward → redeem change → record net.
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
let payload = hyper::body::to_bytes(req.into_body())
.await
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
let payload_str = String::from_utf8_lossy(&payload).to_string();
// S3: the standalone frontend posts full history straight here with no
// assistant loop (and no egress screen) behind it.
if let Some(kind) = forward_screen(&payload_str, data_dir).await {
tracing::error!(
kind,
"routstr proxy: blocked chat forward — secret-shaped content"
);
return Ok(blocked_secret_shaped());
}
let mut body: Value = match serde_json::from_str(&payload_str) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request body is not valid JSON" } }),
));
}
};
let Some(model_id) = body.get("model").and_then(|v| v.as_str()).map(String::from) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request is missing a model id" } }),
));
};
// D-05 gate, checked before any network I/O: a zero allowance means
// Routstr is refused outright, with the UI path that arms it.
let mut budget = crate::assistant::AssistantBudget::load(data_dir).await;
if budget.allowance_sats == 0 {
return Ok(budget_refusal(
"Routstr is disabled on this node — set a sats budget in Settings → System → \
Routstr AI budget to enable it."
.to_string(),
));
}
let remaining = budget.remaining_sats();
if remaining == 0 {
return Ok(budget_refusal(format!(
"This period's Routstr budget is spent ({} of {} sats). Raise the allowance in \
Settings → System → Routstr AI budget to continue.",
budget.spent_sats, budget.allowance_sats
)));
}
// Price the request from the catalog. An unknown model is a caller bug
// (the dropdown only offers catalog models), not a reason to guess a
// price.
let models = match fetch_models().await {
Ok(m) => m,
Err(e) => {
tracing::warn!("routstr proxy: cannot price request, models fetch failed: {e}");
return Ok(bad_gateway(
"Routstr model catalog is unreachable — cannot price this request",
));
}
};
let Some(pricing) = pricing_for_model(&models, &model_id) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": format!("unknown Routstr model: {model_id}") } }),
));
};
// Force the shape this forwarder actually supports: non-streaming, with
// an explicit, capped generation limit (T-13-88).
let max_tokens = body
.get("max_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(MAX_COMPLETION_TOKENS)
.min(MAX_COMPLETION_TOKENS);
body["stream"] = json!(false);
body["max_tokens"] = json!(max_tokens);
let price_sats = estimate_price_sats(&pricing, payload_str.len(), max_tokens);
if price_sats > remaining {
return Ok(budget_refusal(format!(
"This request quotes ~{price_sats} sats but only {remaining} sats remain in this \
period's Routstr budget (Settings → System → Routstr AI budget)."
)));
}
// Pay via the ONE budget-capped primitive (T-13-89) — same call, same
// mint list as the fallback leg in backends/routstr.rs.
let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir)
.await
.map(|m| m.mints)
.unwrap_or_default();
let token = match crate::swarm::payment::auto_pay_token(
data_dir,
&budget.payment_policy(),
&accepted_mints,
price_sats,
)
.await?
{
Some(t) => t,
None => {
return Ok(budget_refusal(format!(
"The node wallet could not fund this request (~{price_sats} sats) — check the \
ecash balance and accepted mints in Settings → Wallet."
)));
}
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/chat/completions");
let resp = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// The token never reached the provider — reclaim it into our own
// wallet so the sats aren't stranded, and record nothing.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(_) => tracing::info!(
"routstr proxy: upstream send failed ({e}); unsent payment token reclaimed"
),
Err(re) => tracing::warn!(
"routstr proxy: upstream send failed ({e}) AND reclaiming the unsent token \
failed ({re}) — {price_sats} sats may be stranded in the token"
),
}
return Ok(bad_gateway("Routstr provider is unreachable"));
}
};
let status = resp.status();
// Change comes back as a Cashu token header (docs.routstr.com names both
// spellings across versions); redeem it so only the net leaves the
// allowance.
let refund_token = ["x-cashu-refund", "x-cashu"]
.iter()
.find_map(|h| resp.headers().get(*h))
.and_then(|v| v.to_str().ok())
.map(String::from);
let resp_body = resp.bytes().await.unwrap_or_default();
let mut reclaimed = 0u64;
if let Some(refund) = refund_token {
match crate::wallet::ecash::receive_token(data_dir, &refund).await {
Ok(sats) => reclaimed = sats,
Err(e) => tracing::warn!("routstr proxy: redeeming the change token failed: {e}"),
}
} else if !status.is_success() {
// The provider refused the request (e.g. "mint unreachable") and
// sent no change — if it never actually redeemed our token, the
// proofs are still ours to take back. If it DID redeem and then
// failed, this reclaim fails harmlessly and the spend stands.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(sats) => {
reclaimed = sats;
tracing::info!(
"routstr proxy: upstream refused (HTTP {status}); unredeemed payment token \
reclaimed ({sats} sats)"
);
}
Err(e) => tracing::warn!(
"routstr proxy: upstream refused (HTTP {status}) and the payment token could \
not be reclaimed ({e}) — treating the {price_sats} sats as spent"
),
}
}
let net_sats = price_sats.saturating_sub(reclaimed);
if net_sats > 0 {
if let Err(e) = budget.record_spend(data_dir, net_sats).await {
tracing::warn!(
error = %e,
"routstr proxy: failed to persist the budget spend (the payment itself already happened)"
);
}
}
tracing::info!(
model = %model_id,
quoted = price_sats,
reclaimed,
net = net_sats,
status = %status,
"routstr proxy: forwarded paid chat completion"
);
Ok(Response::builder()
.status(status.as_u16())
.header("Content-Type", "application/json")
.body(Body::from(resp_body))
.unwrap_or_else(|_| Response::new(Body::from("{}"))))
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_store() -> SessionStore {
let path = std::env::temp_dir().join(format!(
"archy-routstr-proxy-test-sessions-{}.json",
rand::RngCore::next_u64(&mut rand::rngs::OsRng)
));
SessionStore::new_for_tests(path)
}
fn req(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
if let Some(c) = cookie {
builder = builder.header("cookie", format!("session={c}"));
}
builder.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn models_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("GET", "/aiui/api/routstr/models", None, "");
let resp = route_routstr_proxy(&store, data_dir.path(), r, "/aiui/api/routstr/models")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn chat_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("POST", "/aiui/api/routstr/chat/completions", None, "{}");
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
/// path BEFORE any pricing/network I/O — this test runs fully offline.
#[tokio::test]
async fn chat_with_zero_allowance_is_refused_offline() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"some-model","messages":[{"role":"user","content":"hi"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let v: Value = serde_json::from_slice(&body).unwrap();
let msg = v["error"]["message"].as_str().unwrap();
assert!(msg.contains("Settings"), "refusal must name the UI path");
}
#[tokio::test]
async fn chat_body_carrying_bip39_is_blocked() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"m","messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn price_estimate_is_conservative_and_never_zero() {
// A free/garbled pricing entry still quotes at least 1 sat.
assert_eq!(estimate_price_sats(&SatsPricing::default(), 100, 1024), 1);
// Live-catalog-shaped numbers (deepseek-v4-flash, 2026-08-14):
// request 0.001, prompt ~0.000178/tok, completion ~0.000267/tok.
let p = SatsPricing {
prompt: 0.000178,
completion: 0.000267,
request: 0.001,
};
let quote = estimate_price_sats(&p, 4000, 1024);
// ~0.18 + ~0.27 + flat, with margin → rounds up to 1 sat.
assert_eq!(quote, 1);
// A pricier model scales with the prompt.
let expensive = SatsPricing {
prompt: 0.05,
completion: 0.1,
request: 1.0,
};
let quote = estimate_price_sats(&expensive, 40_000, 1024);
assert!(quote >= 600, "quote {quote} should reflect real rates");
}
#[test]
fn pricing_lookup_finds_exact_model_id() {
let models = json!({ "data": [
{ "id": "a-model", "sats_pricing": { "prompt": 0.1, "completion": 0.2, "request": 1.0 } },
{ "id": "other", "sats_pricing": { "prompt": 0.3 } }
]});
let p = pricing_for_model(&models, "a-model").unwrap();
assert_eq!(p.request, 1.0);
assert!(pricing_for_model(&models, "missing").is_none());
}
}
-62
View File
@@ -42,13 +42,6 @@ impl RpcHandler {
"port": g.port,
"app_id": g.app_id,
"app_name": g.app_name,
// Is the login challenge active on this port right now
// (manifest default + operator override, resolved)?
"gate_enabled": g.auth_enabled,
// Whether an operator override is recorded, and what the
// manifest would do without it — the UI needs all three
// to render a meaningful toggle.
"override": crate::container::app_gate_config::gate_override(&g.app_id),
})
})
.collect();
@@ -63,59 +56,4 @@ impl RpcHandler {
"exempt": exempt,
}))
}
/// `security.set-app-gate` — the operator's per-app gate toggle.
///
/// Params: `{ id: "<app_id>", enabled: true | false | null }`.
/// `enabled: null` clears the override so the manifest default applies
/// again. Takes effect on the next request (the gate resolves per-request
/// policy from the live port map) — no rebind, no restart.
pub(in crate::api::rpc) async fn handle_set_app_gate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
.to_string();
let enabled = match params.get("enabled") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Bool(b)) => Some(*b),
Some(other) => anyhow::bail!("enabled must be true, false or null, got {other}"),
};
// Only apps the gate actually fronts have a challenge to toggle.
// Writing an override for anything else would sit silently in the
// config doing nothing — reject instead so a typo'd id is loud.
let port_map = self.app_gate.port_map().await;
if !port_map.gated_ports().any(|g| g.app_id == app_id) {
anyhow::bail!(
"'{app_id}' has no gate-fronted ports — nothing to toggle \
(auth: none/local ports are manifest-declared, not runtime-toggled)"
);
}
crate::container::app_gate_config::write_gate_override(&app_id, enabled)
.map_err(|e| anyhow::anyhow!("Failed to persist gate override: {e}"))?;
// Rebuild the port map now so the change is live on the next request
// instead of after the next 60s sweep.
self.app_gate.refresh().await;
let effective: Vec<serde_json::Value> = self
.app_gate
.port_map()
.await
.gated_ports()
.filter(|g| g.app_id == app_id)
.map(|g| serde_json::json!({ "port": g.port, "gate_enabled": g.auth_enabled }))
.collect();
tracing::info!(
app = %app_id,
override_ = ?enabled,
"app gate override updated by operator"
);
Ok(serde_json::json!({ "id": app_id, "override": enabled, "ports": effective }))
}
}
@@ -344,7 +344,6 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
"bitcoin.relay-update-settings" => {
@@ -133,7 +133,6 @@ impl RpcHandler {
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
@@ -266,12 +265,6 @@ impl RpcHandler {
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
@@ -495,7 +488,6 @@ impl RpcHandler {
// System monitoring
"security.app-gate-status" => self.handle_app_gate_status().await,
"security.set-app-gate" => self.handle_set_app_gate(params).await,
"system.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await,
@@ -511,7 +503,6 @@ impl RpcHandler {
"ai.permissions.set" => self.handle_ai_permissions_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
@@ -696,22 +696,6 @@ impl RpcHandler {
anyhow::bail!("Refusing to peer with self");
}
// Bind the DID to the advertised pubkey. Without this the signature
// check below is self-referential (the caller signs over a pubkey it
// also supplies), so a consistent-but-unrelated keypair would pass.
// The DID-rotation handler already enforces the same invariant.
match identity::did_key_from_pubkey_hex(pubkey) {
Ok(derived) if derived == did => {}
Ok(derived) => {
tracing::warn!(peer_did = %did, derived_did = %derived, "Rejected peer-joined: DID does not match pubkey");
anyhow::bail!("DID does not match pubkey");
}
Err(e) => {
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: invalid pubkey");
anyhow::bail!("Invalid pubkey");
}
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str());
match signature {
@@ -719,16 +703,10 @@ impl RpcHandler {
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
Ok(true) => {}
Ok(false) => {
_ => {
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
anyhow::bail!("Invalid signature");
}
Err(e) => {
// Malformed hex / wrong length — distinguish from a
// genuine mismatch so the log tells us which it was.
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: malformed signature");
anyhow::bail!("Invalid signature");
}
}
}
None => {
@@ -607,81 +607,9 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
// LND returns r_hash base64-encoded; the lookup endpoint the Receive
// flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the
// ready-to-use form.
let r_hash_hex = {
use base64::Engine as _;
body.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default()
};
Ok(serde_json::json!({
"payment_request": payment_request,
"amount_sats": amount_sats,
"r_hash_hex": r_hash_hex,
}))
}
/// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's
/// Receive flow so a Lightning payment gets the same "money has arrived"
/// success screen as on-chain (minus the broadcast step: settlement is
/// final). Params: `{ "r_hash_hex": string }`.
pub(in crate::api::rpc) async fn handle_lnd_invoicestatus(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let r_hash_hex = params
.get("r_hash_hex")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?;
if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to query invoice")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg));
}
let settled = body
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64()))
.unwrap_or(0);
Ok(serde_json::json!({
"settled": settled,
"amt_paid_sat": amt_paid_sat,
}))
}
@@ -405,17 +405,9 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type;
// Resource transfer is a native RNS transfer over LoRa — it needs an
// actual radio route to this contact, not just a Reticulum device on
// our end. A federation-only peer with no radio twin fits the size
// and device-type checks but has no dest_prefix to send to; without
// this check the send falls into send_content_resource and fails
// with "Peer is federation-only (no radio twin)" (picture-send,
// 2026-08-07) instead of falling back to the federation path below.
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX
&& svc.has_radio_route(contact_id).await;
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!(
@@ -500,58 +492,15 @@ impl RpcHandler {
)
.await?
} else {
// Federation-only peers have no radio twin for
// send_typed_wire's LoRa dest-prefix resolution — route over
// Tor federation instead, mirroring mesh.send-content's onion
// lookup, or the send fails with "Peer is federation-only (no
// radio twin)" (picture-send from a federation-only contact,
// 2026-08-07).
let federation_onion = {
let state = svc.shared_state();
let peers = state.peers.read().await;
peers
.get(&contact_id)
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
};
let federation_onion = match federation_onion {
Some((Some(pubkey_hex), did)) => {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == pubkey_hex)
.map(|n| n.onion.clone())
.or_else(|| {
did.as_ref().and_then(|d| {
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
})
})
}
_ => None,
};
if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(
contact_id,
&onion,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
};
@@ -641,16 +590,6 @@ impl RpcHandler {
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
// A Reticulum device on our end doesn't mean THIS peer is radio
// reachable — a federation-only contact (no radio twin) has no dest
// prefix for a resource transfer, even though it's small enough and
// our device type qualifies. Without this check the frontend was
// steered into mesh.send-content-inline's resource-transfer path,
// which fails with "Peer is federation-only (no radio twin)"
// (picture-send, 2026-08-07); the tier below now defers to the
// has_tor branches for such peers, which route via mesh.send-content
// (federation) instead.
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
@@ -659,7 +598,7 @@ impl RpcHandler {
} else {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
(
"resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer",
@@ -64,12 +64,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be",
"cannot",
"Password",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
// running" text the update UI matches on to join an in-flight apply
// instead of showing a false failure. Every such message starts "Update".
"Update",
// The federation escalation sentinel. "Password" above does NOT cover
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
// so the frontend's isPasswordRequired() never saw it and the
@@ -172,20 +166,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"No pending seed generation",
"Submitted words",
"Already set up",
// Ecash backup phrase — these two ARE the feature's safety rails, and
// masking them made it dangerous rather than merely opaque. "That is
// not a valid BIP-39 recovery phrase… check for typos" is the whole
// help someone gets when a pasted phrase has a bad word; and "This
// wallet already has a backup phrase… reveal and write down the
// current phrase first, then confirm to replace it" is the warning
// that stops an operator orphaning the words their balance was minted
// under. Behind "check server logs" the first is unactionable and the
// second is invisible.
"That is not a valid BIP-39",
"This wallet already has a backup phrase",
"This wallet has no backup phrase yet",
// Restore against a mint that never implemented NUT-09.
"This mint does not support restoring",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
@@ -209,27 +189,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
mod sanitize_tests {
use super::sanitize_error_message;
/// The ecash import errors are the feature's safety rails. If the
/// sanitizer eats them, a bad paste gives no hint and — worse — the
/// warning about replacing an established phrase never reaches the person
/// about to do it.
#[test]
fn ecash_backup_phrase_errors_reach_the_operator() {
for msg in [
"That is not a valid BIP-39 recovery phrase: invalid checksum. Check for typos",
"This wallet already has a backup phrase. Importing a different one means coins \
minted under the current phrase will no longer be restorable from words",
"This wallet has no backup phrase yet, so there is nothing to restore from.",
"This mint does not support restoring from a backup phrase (NUT-09).",
] {
let out = sanitize_error_message(msg);
assert_ne!(
out, "Operation failed. Check server logs for details.",
"swallowed: {msg}"
);
}
}
#[test]
fn password_required_sentinel_passes_through_verbatim() {
// The UI machine-reads this sentinel (isPasswordRequired checks
+71 -133
View File
@@ -1,6 +1,6 @@
use super::RpcHandler;
use crate::network::router as net_router;
use anyhow::{Context, Result};
use anyhow::Result;
use archipelago_openwrt::{
detect,
router::Router,
@@ -38,20 +38,7 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
// scan_subnet is `async` in name only: up to 255 SEQUENTIAL blocking
// TCP probes at 500ms each (~2 min on a /24 that silently drops),
// plus a blocking SSH verify per candidate. Inline, one click of
// "scan for routers" held a tokio worker for that whole time.
let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(detect::scan_subnet(
subnet,
prefix,
&ssh_user,
&ssh_password,
))
})
.await
.context("openwrt scan task")?;
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
Ok(serde_json::json!({ "routers": ips }))
@@ -100,80 +87,8 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
// The SSH session is blocking (ssh2 over std TcpStream). Run it on the
// blocking pool: inline it used to park a tokio worker for the whole
// exchange, and against an unreachable router (the gateway that stayed
// behind after a node moved networks) the periodic dashboard poll
// stalled unrelated RPCs for tens of seconds — long enough that TOTP
// codes expired in flight (framework-pt, 2026-08-15).
let status = {
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<serde_json::Value> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// System info
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
let uptime_secs: u64 = router
.run_ok("cat /proc/uptime")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
let tollgate = if tollgate_installed {
serde_json::json!({
"installed": true,
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
};
// WiFi interfaces
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
let wan_status = wan::get_wan_status(&router);
Ok(serde_json::json!({
"host": host,
"hostname": hostname,
"uptime_secs": uptime_secs,
"release": parse_release(&release),
"tollgate": tollgate,
"wifi_interfaces": wifi_interfaces,
"wan": wan_status,
}))
})
.await
.context("openwrt status task")??
};
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// Persist the connection so other views (e.g. the Home dashboard's
// Network tile) can poll `openwrt.get-status` with no params instead
@@ -192,7 +107,62 @@ impl RpcHandler {
.await;
}
Ok(status)
// System info
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
let uptime_secs: u64 = router
.run_ok("cat /proc/uptime")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
let tollgate = if tollgate_installed {
serde_json::json!({
"installed": true,
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
};
// WiFi interfaces
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
let wan_status = wan::get_wan_status(&router);
Ok(serde_json::json!({
"host": host,
"hostname": hostname,
"uptime_secs": uptime_secs,
"release": parse_release(&release),
"tollgate": tollgate,
"wifi_interfaces": wifi_interfaces,
"wan": wan_status,
}))
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
@@ -258,21 +228,9 @@ impl RpcHandler {
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
// Blocking SSH session, and provision runs `opkg install` over it —
// minutes of held worker if the router stalls mid-exchange.
{
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
let config = config.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
tokio::runtime::Handle::current().block_on(tollgate::provision(&router, &config))
})
.await
.context("openwrt provision task")??;
}
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
tollgate::provision(&router, &config).await?;
Ok(serde_json::json!({
"ok": true,
@@ -321,20 +279,10 @@ impl RpcHandler {
.or_else(|| saved.password.clone())
.unwrap_or_default();
// A radio scan is seconds of SSH round-trips even on a healthy
// router; keep it off the runtime.
let networks = {
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<wifi_scan::ScannedNetwork>> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
wifi_scan::scan_networks(&router)
})
.await
.context("openwrt wifi scan task")??
};
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let networks = wifi_scan::scan_networks(&router)?;
let result: Vec<serde_json::Value> = networks
.iter()
.map(|n| {
@@ -409,6 +357,9 @@ impl RpcHandler {
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig {
ssid: ssid.clone(),
password,
@@ -417,20 +368,7 @@ impl RpcHandler {
dhcp_limit,
masq,
};
// Reconfiguring WAN drops and re-establishes the router's uplink, so
// the SSH exchange can stall for its full timeout budget mid-command.
{
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
wan::configure_wisp(&router, &config)
})
.await
.context("openwrt configure-wan task")??;
}
wan::configure_wisp(&router, &config)?;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
}
@@ -2525,7 +2525,7 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
// function's volume-aware `prune=` line, which bitcoind never read either
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
// computes DISK_GB_VALUE and hardcodes -prune=50000 on small volumes — a
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
// real volume-aware prune fix belongs there, not in a conf file nothing
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
@@ -365,18 +365,8 @@ impl RpcHandler {
// after uninstall. The reconciler owns a manifest map independent of
// podman state, so a raw `podman rm` alone is not enough.
if let Some(orchestrator) = &self.orchestrator {
let mut teardown_errors = Vec::new();
for app_id in orchestrator_uninstall_app_ids(package_id) {
if let Err(err) = orchestrator.remove(&app_id, preserve_data).await {
teardown_errors.push(format!("{app_id}: {err:#}"));
}
}
if !teardown_errors.is_empty() {
return Err(anyhow::anyhow!(
"Uninstall {} aborted: failed to remove declarative app unit(s): {}",
package_id,
teardown_errors.join("; ")
));
let _ = orchestrator.remove(&app_id, preserve_data).await;
}
}
@@ -1415,14 +1405,6 @@ async fn repair_before_package_start(container_name: &str) {
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
_ => {}
}
// Reap this app's ghost containers before anything tries to start it.
// A ghost (process tree alive, podman record gone) still owns the
// published port and the data-dir file locks, so the replacement either
// fails to bind (`address already in use`) or starts and dies on the
// lock — and `Restart=always` loops it there forever. Ordered before
// the port cleanup below: killing the owner is what actually frees the
// port, and the port sweep alone cannot tell a ghost from a live app.
crate::container::ghost_reaper::reap_for_app(container_name).await;
cleanup_runtime_host_ports(container_name).await;
}
@@ -2192,11 +2174,6 @@ mod tests {
assert!(!is_missing_container_error("Error: OCI runtime error"));
}
#[test]
fn single_app_uninstall_targets_its_declarative_unit() {
assert_eq!(orchestrator_uninstall_app_ids("cuprate"), vec!["cuprate"]);
}
#[test]
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
+1 -44
View File
@@ -52,18 +52,6 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
.parse()
.context("Invalid mnemonic in memory")?;
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
// Establish the ecash wallet's NUT-13 phrase here too — this is the last
// moment the master seed exists in plaintext during onboarding, and the
// ecash wallet needs its own phrase on disk to mint restorable proofs
// without a password prompt on every background swap. Best-effort: a node
// that fails here still onboards, mints valid coins, and can establish the
// phrase later from Settings → Back up ecash.
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await {
tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}");
}
*state = None;
Ok(true)
}
@@ -188,29 +176,6 @@ pub(in crate::api::rpc) async fn restore_node_identity_from_words(
}
impl RpcHandler {
/// Push the on-disk node identity's pubkey into the live `server_info`
/// snapshot. `seed.generate` / `seed.restore` rewrite `identity/node_key`,
/// but `server_info.pubkey` was only seeded at boot — until the next
/// restart every federation peer-joined advertised the stale boot key
/// while signing with the new seed-derived key, so receivers rejected it
/// ("Invalid signature") on every 90s heal tick (2026-08-16). Mirrors the
/// DID-rotation handler, which already does this refresh.
async fn refresh_server_pubkey_from_disk(&self) {
let identity_dir = self.config.data_dir.join("identity");
let identity = match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = %e, "Could not reload node identity after seed write — server_info.pubkey stays stale until restart");
return;
}
};
let (mut data, _) = self.state_manager.get_snapshot().await;
if data.server_info.pubkey != identity.pubkey_hex() {
data.server_info.pubkey = identity.pubkey_hex();
self.state_manager.update_data(data).await;
}
}
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
@@ -277,10 +242,6 @@ impl RpcHandler {
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// The node key on disk just changed — keep the live snapshot's pubkey
// in lockstep (see refresh_server_pubkey_from_disk for why).
self.refresh_server_pubkey_from_disk().await;
// fips_key is now on disk — auto-activate FIPS so the user doesn't
// have to hit a manual Start button. Detached task;
// the onboarding RPC returns immediately.
@@ -389,11 +350,7 @@ impl RpcHandler {
)
.context("Invalid words array")?;
let result =
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words)
.await?;
self.refresh_server_pubkey_from_disk().await;
Ok(result)
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
}
/// Encrypt and save the mnemonic to disk for convenience backup.
+13 -76
View File
@@ -1248,30 +1248,6 @@ impl RpcHandler {
}
}
/// system.node-ca.generate — run the node's (idempotent) CA setup so the
/// dashboard can offer certificate generation as a button. WebUI rule:
/// users must never be pointed at a terminal; the script reuses an
/// existing CA and only reissues the leaf, so re-running is safe.
pub(in crate::api::rpc) async fn handle_system_node_ca_generate(
&self,
) -> Result<serde_json::Value> {
let script = "/opt/archipelago/scripts/setup-node-ca.sh";
if tokio::fs::metadata(script).await.is_err() {
anyhow::bail!(
"The certificate setup script is not on this node yet — it arrives with the next update."
);
}
let status = host_sudo(&["/usr/bin/bash", script]).await?;
if !status.success() {
anyhow::bail!(
"Certificate generation failed (exit {:?}) — see the node log for detail",
status.code()
);
}
info!("Node CA generated/reissued via dashboard");
Ok(serde_json::json!({ "generated": true }))
}
/// system.kiosk-display.get — Current kiosk display preset + whether this
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
@@ -1292,14 +1268,7 @@ impl RpcHandler {
} else {
"auto"
};
let graphics = if conf.contains("KIOSK_GRAPHICS=performance") {
"performance"
} else if conf.contains("KIOSK_GRAPHICS=quality") {
"quality"
} else {
"auto"
};
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset, "graphics": graphics }))
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
}
/// system.kiosk-display.set — Write the kiosk display preset and restart
@@ -1310,56 +1279,24 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let preset = params.get("preset").and_then(|v| v.as_str());
let graphics = params.get("graphics").and_then(|v| v.as_str());
if preset.is_none() && graphics.is_none() {
anyhow::bail!("Missing preset or graphics");
}
let preset = params
.get("preset")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
// The conf carries two independent settings (display scale preset +
// graphics tier). A set of one must not clobber the other, so the
// half not being changed is carried over from the file as-is.
let existing = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
.await
.unwrap_or_default();
let display_part = match preset {
let conf = match preset {
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
// 1080p TV -> 1.5, laptop panels -> 1.0.
Some("auto") => String::new(),
"auto" => String::new(),
// Biggest UI: every panel targets a 1280-wide layout.
Some("large") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
// Full-HD layout on any panel that can carry it.
Some("balanced") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
// No scaling: native CSS viewport, most content, smallest UI.
Some("native") => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
Some(other) => anyhow::bail!("Unknown display preset: {other}"),
None => existing
.lines()
.filter(|l| l.starts_with("ARCHIPELAGO_KIOSK_"))
.map(|l| format!("{l}\n"))
.collect(),
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
other => anyhow::bail!("Unknown display preset: {other}"),
};
let graphics_part = match graphics {
// Auto: the launcher classifies the hardware itself (CPU/iGPU
// generation) — legacy boxes keep the choppy-audio-safe flags,
// modern iGPUs get GPU rasterization.
Some("auto") => String::new(),
// Force the conservative legacy flag set (troubleshooting).
Some("performance") => "KIOSK_GRAPHICS=performance\n".to_string(),
// Force the modern flag set even on unclassified hardware.
Some("quality") => "KIOSK_GRAPHICS=quality\n".to_string(),
Some(other) => anyhow::bail!("Unknown graphics mode: {other}"),
None => existing
.lines()
.find(|l| l.starts_with("KIOSK_GRAPHICS="))
.map(|l| format!("{l}\n"))
.unwrap_or_default(),
};
let conf = format!("{display_part}{graphics_part}");
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
if conf.is_empty() {
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
@@ -1395,8 +1332,8 @@ impl RpcHandler {
])
.await;
info!(?preset, ?graphics, "Kiosk display settings applied");
Ok(serde_json::json!({ "preset": preset, "graphics": graphics, "applied": true }))
info!(preset, "Kiosk display preset applied");
Ok(serde_json::json!({ "preset": preset, "applied": true }))
}
}
+4 -15
View File
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
/// Read disk usage via `df` for a given path.
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size,avail", path])
.args(["--block-size=1", "--output=used,size", path])
.output()
.await
.context("Failed to run df")?;
@@ -189,22 +189,11 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse()
.context("parse df used")?;
// Raw `size` includes the filesystem's root-reserved blocks (5% by default
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
// Reporting it as capacity told the dashboard there were 251 GiB free when
// only 159 GiB were writable. Callers derive free as total - used, so total
// must mean "what can actually be used".
let _size: u64 = parts
let total: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
.parse()
.context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
.context("parse df total")?;
Ok((used, total))
}
-227
View File
@@ -36,51 +36,6 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-network` — which ecash network this node is on, and the
/// balance sitting in the *other* one so the UI can say what switching
/// would reveal rather than appearing to lose money.
pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> {
let current = ecash::load_network(&self.config.data_dir).await;
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({
"network": current,
"is_test": current.is_test(),
"mint_url": wallet.mint_url,
"balance_sats": wallet.balance(),
}))
}
/// `wallet.ecash-set-network` — switch between real and test ecash.
///
/// Each network keeps its own wallet file, so this never moves, merges or
/// deletes coins: switching away parks the current balance and switching
/// back finds it exactly as it was.
pub(super) async fn handle_wallet_ecash_set_network(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let requested = params
.get("network")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing network ('mainnet' or 'testnet')"))?;
let network = match requested {
"mainnet" => ecash::EcashNetwork::Mainnet,
"testnet" => ecash::EcashNetwork::Testnet,
other => anyhow::bail!("Unknown ecash network '{other}' — use 'mainnet' or 'testnet'"),
};
ecash::save_network(&self.config.data_dir, network).await?;
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
tracing::info!(?network, "ecash network switched by operator");
Ok(serde_json::json!({
"network": network,
"is_test": network.is_test(),
"mint_url": wallet.mint_url,
"balance_sats": wallet.balance(),
}))
}
pub(super) async fn handle_wallet_ecash_mint(
&self,
params: Option<serde_json::Value>,
@@ -206,17 +161,10 @@ impl RpcHandler {
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
// sidecar). Detect by prefix and route accordingly.
if is_cashu_token(token) {
// Which mint issued it, for the success screen. Ecash leaves no
// public trace once redeemed, so the mint URL is the only thing a
// person can quote later if the payment is ever disputed.
let mint_url = crate::wallet::cashu::CashuToken::deserialize(token)
.ok()
.and_then(|t| t.mint_urls().first().map(|m| m.to_string()));
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
return Ok(serde_json::json!({
"received_sats": amount,
"kind": "cashu",
"mint_url": mint_url,
}));
}
@@ -246,181 +194,6 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase
/// yet, and therefore whether its coins can be restored at all.
///
/// Deliberately says nothing secret. `active: false` is the honest answer
/// for a node that predates NUT-13: its existing proofs live in exactly one
/// file and nothing can bring them back, which the UI needs to be able to
/// say plainly rather than implying a backup exists.
pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result<serde_json::Value> {
let data_dir = &self.config.data_dir;
let active = crate::wallet::nut13::seed_exists(data_dir);
let source = match crate::wallet::nut13::load_seed(data_dir).await {
Ok(Some(seed)) => Some(seed.source()),
_ => None,
};
// A phrase can always be established. Whether the node has an
// encrypted master seed decides only *which kind*: derived from it
// (the node's 24 words already cover the ecash), or independent (the
// phrase is the only copy). The UI needs both facts to set the right
// expectation before the operator commits to writing something down.
Ok(serde_json::json!({
"active": active,
"source": source,
"can_activate": true,
"derivable_from_node_seed": crate::seed::seed_exists(data_dir),
}))
}
/// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and
/// establish them from the node's master seed if this is the first time.
///
/// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated
/// session, password re-verification, TOTP when enabled. The words are
/// returned to the caller only and never logged.
///
/// Reveal doubles as activation because the master seed is encrypted at
/// rest: this password prompt is the only moment the node can legitimately
/// open it, so it is also the only moment the ecash phrase can be derived
/// from it. A node that has never been here mints valid but unrecoverable
/// proofs; one visit fixes that for every proof minted afterwards.
pub(super) async fn handle_wallet_ecash_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let data_dir = &self.config.data_dir;
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
// Already established: just open it. No master seed needed, so this
// still works on a node whose backup passphrase has been forgotten.
if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? {
password.zeroize();
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": false,
}));
}
// No encrypted master seed to derive from — common on nodes onboarded
// before that step existed. The choice here is not "derived or
// independent", it is "independent or no backup at all", so we make
// one and label it honestly. Every surface that shows an
// `independent` phrase says the node's own recovery phrase does not
// cover it.
if !crate::seed::seed_exists(data_dir) {
password.zeroize();
let seed = crate::wallet::nut13::establish_independent(data_dir).await?;
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}));
}
// The backup passphrase may differ from the login password — same
// fallback `seed.reveal` uses.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| password.clone());
let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await;
password.zeroize();
let mnemonic = master.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?;
let words = seed.words();
Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}))
}
/// `wallet.ecash-seed-import` — adopt a phrase from another NUT-13 wallet.
///
/// Gated like every other route that touches key material. Replacing an
/// established phrase additionally needs `confirm: true`, because coins
/// minted under the old one stop being restorable from words — they stay
/// spendable, but a restore will not find them. The old phrase is archived
/// beside the wallet rather than overwritten.
pub(super) async fn handle_wallet_ecash_seed_import(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let words = params
.get("words")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("A recovery phrase is required"))?
.to_string();
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
password.zeroize();
let seed =
crate::wallet::nut13::import_mnemonic(&self.config.data_dir, &words, confirm).await?;
Ok(serde_json::json!({
"source": seed.source(),
"word_count": seed.words().len(),
}))
}
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
/// phrase by asking a mint which re-derived secrets it has signed.
///
/// Defaults to the wallet's own mint; `mint_url` targets another one, for
/// a wallet whose coins were spread across mints.
pub(super) async fn handle_wallet_ecash_restore(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) {
Some(url) if !url.trim().is_empty() => url.trim().to_string(),
_ => {
crate::wallet::ecash::load_wallet(&self.config.data_dir)
.await?
.mint_url
}
};
let outcome =
crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?;
Ok(serde_json::json!({
"mint_url": mint_url,
"recovered_sats": outcome.recovered_sats,
"recovered_proofs": outcome.recovered_proofs,
"already_spent": outcome.already_spent,
"keysets_scanned": outcome.keysets_scanned,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
+1 -30
View File
@@ -40,15 +40,6 @@ pub struct GatedPort {
/// companion UIs proxy that cookie to the daemon's authenticated
/// endpoints; for every other app the gate strips its own credential.
pub session_passthrough: bool,
/// Does the gate challenge for the dashboard login on this port?
///
/// Default comes from the manifest (`auth: gated`/undeclared → true,
/// `auth: open` → false); the operator's runtime override
/// (`app_gate_config`, Settings → app → App gate) wins over both.
/// False does NOT release the port — the gate keeps binding and
/// proxying (frame-header fixes, app-down page, Tor upstream); it just
/// forwards every request to the app's own authentication.
pub auth_enabled: bool,
}
/// A port deliberately left unauthenticated, and the manifest's stated reason.
@@ -196,18 +187,6 @@ pub fn build_port_map() -> PortMap {
}
}
// The operator's runtime override wins over the manifest default, in
// both directions: un-gate an app that fronts its own login, or force
// the challenge back onto an `auth: open` port. Overrides only toggle
// the challenge on gate-fronted ports — they never bind or release
// anything, so a stale override cannot expose or strand a port.
let overrides = crate::container::app_gate_config::all_gate_overrides();
for gp in map.gated.values_mut() {
if let Some(enabled) = overrides.get(&gp.app_id) {
gp.auth_enabled = *enabled;
}
}
map.exempt.sort_by_key(|e| e.port);
map
}
@@ -250,10 +229,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
// Explicit opt-in: the app is on loopback and the daemon
// owns the external addresses. This is the ONLY way a
// port gets bound by the gate, regardless of `bind`.
// `open` is the same takeover with the login challenge
// defaulted off — the app fronts its own authentication
// (rationale-required, see PortAuth::Open).
PortAuth::Gated | PortAuth::Open => {
PortAuth::Gated => {
map.gated.insert(
port.host,
GatedPort {
@@ -263,7 +239,6 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
icon: icon.clone(),
declared: true,
session_passthrough: port.session_passthrough,
auth_enabled: port.auth_policy() == PortAuth::Gated,
},
);
}
@@ -314,10 +289,6 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
// An undeclared port never gets the node session —
// passthrough is an explicit manifest opt-in only.
session_passthrough: false,
// Undeclared ports are challenged wherever the gate
// can stand: reporting-and-protecting is the safe
// default (operator override still applies below).
auth_enabled: true,
},
);
}
+22 -264
View File
@@ -139,14 +139,6 @@ impl AppGate {
app: &GatedPort,
client_ip: IpAddr,
) -> Response<Body> {
// The accept loop captured its GatedPort at bind time; per-request
// policy (the operator's gate on/off toggle, session_passthrough)
// must come from the live map or a Settings change would only apply
// to ports (re)bound after the next sweep. Falls back to the bound
// snapshot when the port momentarily leaves the map mid-refresh.
let live = self.port_map.read().await.gated(app.port).cloned();
let app = live.as_ref().unwrap_or(app);
let path = req.uri().path().to_string();
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
@@ -177,17 +169,6 @@ impl AppGate {
return proxy_to_app(req, app, true).await;
}
// Gate challenge disabled for this app (manifest `auth: open`, or
// the operator's Settings toggle): forward everything to the app's
// own authentication. The Authorization header passes through
// untouched — git clients speak basic-auth to Gitea, API clients
// carry the app's own tokens. Gate cookies are still stripped in
// proxy_to_app (an ungated app must never see the node session),
// and the frame fixes / app-down page still apply.
if !app.auth_enabled {
return proxy_to_app(req, app, false).await;
}
match self.authorize(req.headers(), &app.app_id).await {
// The credential was a cookie (or none was needed): the
// Authorization header, if any, belongs to the app. Forward it.
@@ -539,63 +520,11 @@ async fn proxy_to_app(
let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await {
Ok(mut resp) => {
neutralize_frame_blocking(resp.headers_mut());
resp
}
Ok(resp) => resp,
Err(_) => app_down_page(app),
}
}
/// Make gate-proxied app responses embeddable by the dashboard's My Apps
/// iframe. Apps that were never designed for framing ship
/// `X-Frame-Options: DENY` (Alby Hub) or a CSP `frame-ancestors` directive,
/// and either one makes the embedded app session a dead grey pane — the
/// historical workaround was a bespoke per-app nginx proxy (gitea), which is
/// exactly the per-app patching the manifest platform exists to delete.
///
/// Framing protection exists to stop a FOREIGN origin from framing an authed
/// page and clickjacking it. Behind the gate that threat model is already
/// handled the way the gate's own pages handle it: every proxied request is
/// authenticated by the gate first, and the gate's own responses declare
/// `frame-ancestors 'self' http://*:* https://*:*` (see `page()`) because the
/// dashboard is reached by LAN IP, mDNS name, and onion alike. Upstream
/// X-Frame-Options is dropped entirely; only the `frame-ancestors` directive
/// is removed from the app's CSP — the rest of the app's policy (script-src,
/// connect-src, …) is the app's business and passes through untouched.
fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
headers.remove("x-frame-options");
let Some(csp) = headers.get("content-security-policy") else {
return;
};
let Ok(raw) = csp.to_str() else {
return;
};
if !raw.to_ascii_lowercase().contains("frame-ancestors") {
return;
}
let kept: Vec<&str> = raw
.split(';')
.map(str::trim)
.filter(|d| !d.to_ascii_lowercase().starts_with("frame-ancestors") && !d.is_empty())
.collect();
if kept.is_empty() {
headers.remove("content-security-policy");
return;
}
match header::HeaderValue::from_str(&kept.join("; ")) {
Ok(v) => {
headers.insert("content-security-policy", v);
}
Err(_) => {
// Unrepresentable after filtering — fail open for framing but
// closed for the policy: better to drop a mangled CSP than to
// serve one we rewrote incorrectly.
headers.remove("content-security-policy");
}
}
}
/// Cookie names owned by the gate/daemon, never the app's to see.
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
@@ -750,71 +679,6 @@ fn icon_markup(app: &GatedPort) -> String {
format!(r#"<div class="tile">{inner}</div>"#)
}
/// The dashboard login's badge, reproduced square-for-square: the same 20
/// white rects AnimatedLogo.vue draws, with the same 100ms stagger, inside
/// the same gradient ring. Inline rather than an `<img>` because the shipped
/// `favico-black-v2.svg` bakes its own ring into the artwork — wrapping it in
/// the CSS ring drew a ring inside a ring, which is not what /login shows.
/// (x, y, width, height) as they appear in AnimatedLogo.vue.
const LOGO_RECTS: [(f32, f32, f32, f32); 20] = [
(357.614, 318.0, 71.007, 70.936),
(436.152, 318.0, 72.082, 70.936),
(515.766, 318.0, 72.082, 70.936),
(595.379, 318.0, 71.007, 70.936),
(595.379, 396.46, 71.007, 72.011),
(673.917, 396.46, 72.083, 72.011),
(278.0, 475.994, 72.083, 72.012),
(357.614, 475.994, 71.007, 72.012),
(436.152, 475.994, 72.082, 72.012),
(515.766, 475.994, 72.082, 72.012),
(595.379, 475.994, 71.007, 72.012),
(673.917, 475.994, 72.083, 72.012),
(278.0, 555.529, 72.083, 70.936),
(357.614, 555.529, 71.007, 70.936),
(595.379, 555.529, 71.007, 70.936),
(673.917, 555.529, 72.083, 70.936),
(357.614, 633.989, 71.007, 72.011),
(436.152, 633.989, 72.082, 72.011),
(515.766, 633.989, 72.082, 72.011),
(595.379, 633.989, 71.007, 72.011),
];
fn logo_markup() -> String {
let rects: String = LOGO_RECTS
.iter()
.enumerate()
.map(|(i, (x, y, w, h))| {
format!(
r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="white" class="sq" style="--d:{delay}ms"/>"#,
delay = i * 100,
)
})
.collect();
format!(
r##"<div class="logo"><svg viewBox="0 0 1024 1024" role="img" aria-label="Archipelago" xmlns="http://www.w3.org/2000/svg"><rect width="1024" height="1024" fill="#030202"/>{rects}</svg></div>"##
)
}
/// The main login's in-button spinner, verbatim from Login.vue.
const SPINNER_SVG: &str = r#"<svg class="spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle style="opacity:.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path style="opacity:.75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>"#;
/// Submit feedback: flip the pressed button into its loading face and stop a
/// second press, exactly as /login does. This is the only script on the page,
/// and the CSP admits it by hash — not `'unsafe-inline'` — so an injected
/// `<script>` still cannot run. Everything works without it (the form is a
/// plain POST); losing JS costs only the spinner.
const SUBMIT_FEEDBACK_JS: &str = "document.addEventListener('submit',function(e){var b=e.target.querySelector('button');if(b){b.classList.add('loading');b.disabled=true;}});";
/// `'sha256-…'` CSP source expression for [`SUBMIT_FEEDBACK_JS`]. Computed
/// from the constant itself so the two can never drift apart.
fn submit_feedback_csp_hash() -> String {
use sha2::{Digest, Sha256};
format!(
"'sha256-{}'",
base64_encode(&Sha256::digest(SUBMIT_FEEDBACK_JS.as_bytes()))
)
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
@@ -866,9 +730,7 @@ const LOGIN_BACKGROUNDS: [&str; 4] = [
/// join: the name arrives in a URL, and the gate answers before any
/// authentication, so nothing here may be caller-controlled beyond this set.
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
// Only the rotating backgrounds: the logo badge is inline SVG now, so no
// image asset backs it.
let allowed = LOGIN_BACKGROUNDS.contains(&name);
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg";
if !allowed {
return None;
}
@@ -990,15 +852,8 @@ main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
width:5rem; height:5rem; border-radius:9999px; padding:3px;
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
.logo::after {{ content:''; position:absolute; inset:3px; border-radius:9999px;
background:#000; z-index:0; }}
.logo svg {{ position:relative; z-index:1; width:100%; height:100%;
border-radius:9999px; display:block; }}
/* AnimatedLogo.vue's reveal, timing intact: each square fades in on its own
100ms-step delay over a 3s loop. */
.logo .sq {{ opacity:0; animation:logo-square-in 3s ease-out infinite;
animation-delay:var(--d,0ms); animation-fill-mode:both; }}
@keyframes logo-square-in {{ 0% {{ opacity:0; }} 15% {{ opacity:1; }} 100% {{ opacity:1; }} }}
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
background:#000; padding:.5rem; }}
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
glass with the same inner highlight and drop shadow. */
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
@@ -1019,42 +874,22 @@ input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5re
input::placeholder {{ color:rgba(255,255,255,.4); }}
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
/* .glass-button, longhand — the lift, the lightening and the rim glow on
hover are what make the dashboard's buttons feel alive; the old flat
darken-only hover here read as broken next to /login. */
button {{ position:relative; display:inline-flex; align-items:center;
justify-content:center; width:100%; min-height:44px; padding:.75rem 1.25rem;
border:none; border-radius:.75rem; background:rgba(0,0,0,.6);
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
border-radius:.75rem; background:rgba(0,0,0,.6);
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
transition:transform .3s cubic-bezier(.4,0,.2,1), background-color .2s ease,
box-shadow .3s ease; }}
button::before {{ content:''; position:absolute; inset:0; border-radius:inherit;
padding:2px; background:linear-gradient(135deg, rgba(0,0,0,.8), transparent);
-webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite:xor; mask-composite:exclude; pointer-events:none; }}
button:hover {{ transform:translateY(-2px); background:rgba(0,0,0,.35);
box-shadow:0 12px 32px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.25); }}
button:hover::before {{ background:linear-gradient(135deg, rgba(255,255,255,.3), transparent); }}
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
button:hover {{ background:rgba(0,0,0,.7); }}
button:active {{ transform:translateY(1px); }}
button:disabled {{ opacity:.5; cursor:not-allowed; transform:none; }}
/* Two faces per button; the submit-feedback script flips .loading on. */
button .busy {{ display:none; }}
button.loading .idle {{ display:none; }}
button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
.spin {{ width:1.25rem; height:1.25rem; animation:spin 1s linear infinite; }}
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
font-size:.875rem; text-align:left; }}
</style></head>
<body>{backgrounds}<main><div class="card">{body}</div></main>
<script>{submit_feedback}</script></body></html>"#,
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
submit_feedback = SUBMIT_FEEDBACK_JS,
backgrounds = background_layers(),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
@@ -1074,16 +909,10 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
// login, on any port or scheme, which is exactly the dashboard.
// Anything else — another site embedding it to harvest the node
// password — is still refused.
// script-src admits exactly one script, by hash: the submit-feedback
// snippet above. Injected markup (an app name, an error string) still
// cannot execute — its hash would not match.
.header(
"Content-Security-Policy",
format!(
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
script-src {hash}; form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
hash = submit_feedback_csp_hash(),
),
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
)
.body(Body::from(html))
.expect("static response builds")
@@ -1094,17 +923,15 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{logo}
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
<form method="post" action="{prefix}login">
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
<button type="submit"><span class="idle">Sign in</span><span class="busy">{spinner}Signing in…</span></button>
<button type="submit">Sign in</button>
</form>"#,
logo = logo_markup(),
spinner = SPINNER_SVG,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
@@ -1125,9 +952,8 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
{err}
<form method="post" action="{prefix}totp">
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
<button type="submit"><span class="idle">Verify</span><span class="busy">{spinner}Verifying…</span></button>
<button type="submit">Verify</button>
</form>"#,
spinner = SPINNER_SVG,
icon = icon_markup(app),
name = esc(&app.app_name),
err = error
@@ -1183,7 +1009,6 @@ mod tests {
icon: None,
declared: true,
session_passthrough: false,
auth_enabled: true,
}
}
@@ -1247,50 +1072,6 @@ mod tests {
/// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot.
/// Upstream frame-blocking must not survive the proxy: X-Frame-Options
/// goes away entirely, CSP loses ONLY its frame-ancestors directive —
/// the app's remaining policy must pass through byte-preserving in
/// content (Alby Hub's DENY + strict CSP was the real-world case,
/// archi-dev-box 2026-08-12).
#[test]
fn proxied_responses_lose_frame_blocking_but_keep_the_apps_csp() {
let mut headers = hyper::HeaderMap::new();
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert(
"content-security-policy",
"default-src 'self'; frame-ancestors 'none'; img-src 'self' https://cdn.example"
.parse()
.unwrap(),
);
neutralize_frame_blocking(&mut headers);
assert!(!headers.contains_key("x-frame-options"));
let csp = headers["content-security-policy"].to_str().unwrap();
assert!(!csp.contains("frame-ancestors"));
assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("img-src 'self' https://cdn.example"));
// CSP that is ONLY a frame-ancestors directive disappears entirely.
let mut only = hyper::HeaderMap::new();
only.insert(
"content-security-policy",
"frame-ancestors 'self'".parse().unwrap(),
);
neutralize_frame_blocking(&mut only);
assert!(!only.contains_key("content-security-policy"));
// No frame directives at all → CSP untouched.
let mut plain = hyper::HeaderMap::new();
plain.insert(
"content-security-policy",
"default-src 'self'".parse().unwrap(),
);
neutralize_frame_blocking(&mut plain);
assert_eq!(
plain["content-security-policy"].to_str().unwrap(),
"default-src 'self'"
);
}
#[test]
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
@@ -1323,48 +1104,25 @@ mod tests {
}
/// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background that 404s leaves a black
/// page rather than the dashboard's art. The badge itself is inline SVG —
/// the same 20 squares as the dashboard login's AnimatedLogo — so it can
/// never 404 at all.
/// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body).to_string();
assert_eq!(
html.matches(r#"class="sq""#).count(),
LOGO_RECTS.len(),
"the badge must draw every AnimatedLogo square inline"
);
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
for name in LOGIN_BACKGROUNDS {
assert!(
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
"background {name} is not referenced"
);
}
}
/// The only script the challenge pages may run is the submit-feedback
/// snippet, admitted by hash. The page must carry exactly that script,
/// and the CSP must name its hash — anything injected has a different
/// hash and stays inert.
#[tokio::test]
async fn submit_feedback_script_is_present_and_hash_pinned() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let csp = resp.headers()["Content-Security-Policy"]
.to_str()
.unwrap()
.to_string();
assert!(csp.contains(&format!("script-src {}", submit_feedback_csp_hash())));
assert!(!csp.contains("script-src 'unsafe-inline'"));
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains(&format!("<script>{SUBMIT_FEEDBACK_JS}</script>")));
// Both button faces render: idle label and the spinner face.
assert!(html.contains(r#"<span class="idle">Sign in</span>"#));
assert!(html.contains("Signing in…"));
assert!(html.contains(r#"class="spin""#));
// Every referenced asset must be one the gate will actually serve.
// The logo is the sidebar A mark (favico-black-v2.svg) since the
// 2026-08-05 login-page rework — the old wordmark is off the
// allowlist on purpose.
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
}
/// The allowlist is the whole security boundary for asset serving: the
-460
View File
@@ -171,33 +171,6 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("tor-helper.sh already current"),
Err(e) => warn!("tor-helper sync failed (non-fatal): {:#}", e),
}
match run_welcome_banner_sync().await {
Ok(true) => info!(
"Console welcome banner synchronized (LAN address + .local name, not the WG tunnel IP)"
),
Ok(false) => debug!("Console welcome banner already current (or not an ISO node)"),
Err(e) => warn!("Welcome banner sync failed (non-fatal): {:#}", e),
}
match run_nginx_listener_repair().await {
Ok(true) => info!("nginx HTTPS listeners retargeted to this host's current addresses"),
Ok(false) => debug!("nginx listeners already match this host's addresses"),
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
}
match run_ha_rpc_proxy_bind_repair().await {
Ok(true) => {
info!("HA bitcoind RPC forwarder rebound dynamically — survives network moves now")
}
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
}
match run_pull_never_image_repair().await {
Ok(n) if n > 0 => info!(
retagged = n,
"Healed quadlet image refs orphaned by registry rename"
),
Ok(_) => debug!("All quadlet image refs resolve locally"),
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
}
match run_tor_torrc_repair().await {
Ok(true) => info!("Tor healed at boot (torrc rebuilt and/or daemon restarted)"),
Ok(false) => debug!("Tor healthy and torrc in sync — no heal needed"),
@@ -681,368 +654,6 @@ exit 2
const TOR_HELPER_SH: &str = include_str!("../../../scripts/tor-helper.sh");
const TOR_HELPER_PATH: &str = "/opt/archipelago/scripts/tor-helper.sh";
/// Heal socat forwarder units that were generated with the node's LAN IP
/// baked into `bind=`.
///
/// `archy-ha-btc-rpc-proxy.service` (written on-node during the Pine/HA
/// integration) bound socat to the box's DHCP address at generation time —
/// pasta containers reach the host via its LAN address, so that was the
/// address that worked. Move the box to a new network and the address no
/// longer exists: `bind()` fails and the unit restart-loops forever
/// (framework-pt after relocating, 2026-08-15: restart counter 2446, and
/// Home Assistant's bitcoind sensor dead with it).
///
/// The rewrite computes the bind address at every service start instead, so
/// `Restart=always` itself becomes the heal: plug the box into any network
/// and the next restart binds to the new address.
const HA_RPC_PROXY_UNIT_PATH: &str = "/etc/systemd/system/archy-ha-btc-rpc-proxy.service";
/// Parse `TCP-LISTEN:<port>,bind=<ipv4>` + trailing `TCP:<target>` out of a
/// socat ExecStart line. Returns (listen_port, target).
fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
let after_listen = exec_line.split("TCP-LISTEN:").nth(1)?;
let port = after_listen.split(',').next()?.trim();
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
return None;
}
// Only rewrite units pinned to a concrete address; a unit already using
// a computed bind (or none) needs no heal.
let bind = after_listen
.split("bind=")
.nth(1)?
.split(',')
.next()?
.trim();
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
return None;
}
let target = exec_line.rsplit(" TCP:").next()?.trim();
if target.is_empty() || target == exec_line {
return None;
}
Some((port.to_string(), target.to_string()))
}
fn dynamic_bind_execstart(listen_port: &str, target: &str) -> String {
// `$$` survives systemd's own expansion as a literal `$`, so the command
// substitution runs in the shell at ExecStart time. If the box has no
// default route yet, exit non-zero and let Restart=always retry.
format!(
"ExecStart=/bin/sh -c 'IP=$$(ip -4 route get 1.1.1.1 | sed -n \"s/.*src \\([0-9.]*\\).*/\\1/p\"); \
[ -n \"$$IP\" ] || exit 1; \
exec /usr/bin/socat TCP-LISTEN:{listen_port},bind=$$IP,fork,reuseaddr TCP:{target}'"
)
}
async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
let unit = match tokio::fs::read_to_string(HA_RPC_PROXY_UNIT_PATH).await {
Ok(s) => s,
Err(_) => return Ok(false), // node never grew the forwarder
};
let Some(exec_line) = unit
.lines()
.find(|l| l.trim_start().starts_with("ExecStart="))
else {
return Ok(false);
};
let Some((port, target)) = parse_socat_static_bind(exec_line) else {
return Ok(false); // already dynamic (or not the shape we heal)
};
let healed = unit.replace(exec_line, &dynamic_bind_execstart(&port, &target));
let staged = "/var/lib/archipelago/ha-rpc-proxy.staged";
if let Some(dir) = Path::new(staged).parent() {
tokio::fs::create_dir_all(dir).await.ok();
}
tokio::fs::write(staged, &healed)
.await
.context("stage ha-rpc-proxy unit")?;
let script = format!(
"set -eu\ninstall -m 0644 {staged} {dest}\nsystemctl daemon-reload\nsystemctl restart archy-ha-btc-rpc-proxy 2>/dev/null || true\nexit 0\n",
staged = staged,
dest = HA_RPC_PROXY_UNIT_PATH
);
host_sudo(&["sh", "-lc", &script])
.await
.context("install ha-rpc-proxy unit")?;
Ok(true)
}
/// Re-point `--pull never` quadlets whose image ref no longer matches local
/// storage.
///
/// The catalog signing pass rewrites image refs (bare-IP registry → domain),
/// so a quadlet regenerated with the new ref points at an image the local
/// store only holds under the old name. With `--pull never` the app can
/// never start again on its own — Home Assistant looped 761 restarts on
/// "image not known" (framework-pt, 2026-08-15) while an identical
/// `name:tag` sat in storage under the bare-IP ref. If any local image
/// shares the wanted `name:tag`, retag it; pulling is deliberately NOT
/// attempted here (offline nodes, metered links — the doctor handles pulls).
async fn run_pull_never_image_repair() -> Result<usize> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string());
let quadlet_dir = format!("{home}/.config/containers/systemd");
let mut wanted: Vec<String> = Vec::new();
let mut entries = match tokio::fs::read_dir(&quadlet_dir).await {
Ok(e) => e,
Err(_) => return Ok(0),
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("container") {
continue;
}
let Ok(text) = tokio::fs::read_to_string(&path).await else {
continue;
};
for line in text.lines() {
if let Some(image) = line.trim().strip_prefix("Image=") {
let image = image.trim();
if !image.is_empty() {
wanted.push(image.to_string());
}
}
}
}
if wanted.is_empty() {
return Ok(0);
}
let local = podman_stdout(&["images", "--format", "{{.Repository}}:{{.Tag}}"]).await;
let local: Vec<&str> = local
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.contains("<none>"))
.collect();
let mut retagged = 0usize;
for want in wanted {
if local.iter().any(|l| *l == want) {
continue;
}
// Same `name:tag`, any registry prefix, is the rename we heal.
let Some(name_tag) = want.rsplit('/').next() else {
continue;
};
if !name_tag.contains(':') {
continue;
}
let suffix = format!("/{name_tag}");
let Some(src) = local.iter().find(|l| l.ends_with(&suffix)) else {
continue;
};
let status = tokio::process::Command::new("podman")
.args(["tag", src, &want])
.status()
.await;
match status {
Ok(s) if s.success() => {
info!(from = %src, to = %want, "Retagged image for a --pull never quadlet");
retagged += 1;
}
_ => warn!(from = %src, to = %want, "Image retag failed (non-fatal)"),
}
}
Ok(retagged)
}
async fn podman_stdout(args: &[&str]) -> String {
match tokio::process::Command::new("podman")
.args(args)
.output()
.await
{
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
_ => String::new(),
}
}
/// Keep nginx's per-address HTTPS listeners in step with the addresses the
/// host actually has, and get nginx running if a boot race killed it.
///
/// `scripts/setup-node-ca.sh` writes one `listen <addr>:443 ssl;` per LAN
/// address at the moment it runs (per-address rather than wildcard on
/// purpose: Tailscale holds :443 on the tailnet address). Its idempotency
/// guard then never revisits them. Two ways that takes the WHOLE web UI
/// down — nginx refuses to start if any listen address is missing, so this
/// is not merely an HTTPS outage:
/// 1. The node moves networks and the old address no longer exists.
/// 2. Even in place, nginx starts before DHCP has assigned the address —
/// and nginx.service ships no `Restart=`, so that single failure is
/// permanent until a human intervenes.
/// Both observed on archi-dev-box, 2026-08-15: nginx dead since boot with
/// `bind() to 192.168.63.240:443 failed (99: Cannot assign requested
/// address)`, and the dashboard simply unreachable.
const NGINX_SITES: [&str; 2] = [
"/etc/nginx/sites-available/archipelago-http",
"/etc/nginx/sites-available/archipelago",
];
const NGINX_RESTART_DROPIN: &str =
"/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
/// the same exclusion `setup-node-ca.sh` applies, for the same reason.
async fn host_lan_addrs() -> Vec<String> {
let out = tokio::process::Command::new("ip")
.args(["-o", "-4", "addr", "show", "scope", "global"])
.output()
.await;
let Ok(out) = out else { return Vec::new() };
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_whitespace().nth(3))
.filter_map(|cidr| cidr.split('/').next())
.filter(|a| !is_cgnat(a))
.map(str::to_string)
.collect()
}
fn is_cgnat(addr: &str) -> bool {
let mut parts = addr.split('.');
let (Some(100), Some(second)) = (
parts.next().and_then(|p| p.parse::<u8>().ok()),
parts.next().and_then(|p| p.parse::<u8>().ok()),
) else {
return false;
};
(64..=127).contains(&second)
}
/// Rewrite the `listen <ip>:443 ssl;` set for one config's text. Returns the
/// new text when it differs. Lines for absent addresses are dropped and one
/// line per present address is kept, preserving the file's indentation.
fn retarget_https_listeners(text: &str, present: &[String]) -> Option<String> {
let listen_of = |l: &str| -> Option<String> {
let t = l.trim();
let rest = t.strip_prefix("listen ")?.strip_suffix(":443 ssl;")?;
// Only per-address listeners; `listen 443 ssl ...` has no address.
rest.split('.').count().eq(&4).then(|| rest.to_string())
};
if !text.lines().any(|l| listen_of(l).is_some()) {
return None; // wildcard-only config; nothing address-pinned to heal
}
let stale: Vec<String> = text
.lines()
.filter_map(listen_of)
.filter(|a| !present.contains(a))
.collect();
let existing: Vec<String> = text.lines().filter_map(listen_of).collect();
let missing: Vec<&String> = present.iter().filter(|a| !existing.contains(a)).collect();
if stale.is_empty() && missing.is_empty() {
return None;
}
let indent = text
.lines()
.find(|l| listen_of(l).is_some())
.map(|l| l[..l.len() - l.trim_start().len()].to_string())
.unwrap_or_else(|| " ".to_string());
let mut out: Vec<String> = Vec::new();
let mut wrote_block = false;
for line in text.lines() {
match listen_of(line) {
Some(_) if !wrote_block => {
wrote_block = true;
for a in present {
out.push(format!("{indent}listen {a}:443 ssl;"));
}
}
Some(_) => {} // subsequent old listen lines are replaced by the block
None => out.push(line.to_string()),
}
}
Some(out.join("\n"))
}
async fn run_nginx_listener_repair() -> Result<bool> {
let present = host_lan_addrs().await;
if present.is_empty() {
return Ok(false); // no network yet; a later boot pass will do it
}
let mut changed = false;
for site in NGINX_SITES {
let Ok(text) = tokio::fs::read_to_string(site).await else {
continue;
};
let Some(healed) = retarget_https_listeners(&text, &present) else {
continue;
};
let staged = "/var/lib/archipelago/nginx-listeners.staged";
if let Some(dir) = Path::new(staged).parent() {
tokio::fs::create_dir_all(dir).await.ok();
}
tokio::fs::write(staged, &healed)
.await
.context("stage nginx listeners")?;
// Install behind `nginx -t`, and roll back if the test fails — a bad
// config here would take the dashboard down, which is the very
// failure this repair exists to prevent.
let script = format!(
"set -eu\ncp {site} {site}.bak-listeners\ninstall -m 0644 {staged} {site}\n\
if ! nginx -t 2>/dev/null; then cp {site}.bak-listeners {site}; exit 3; fi\nexit 0\n"
);
let status = host_sudo(&["sh", "-lc", &script]).await?;
match status.code() {
Some(0) => changed = true,
Some(3) => warn!(
site,
"nginx listener repair failed its config test — rolled back"
),
_ => warn!(site, "nginx listener repair helper failed"),
}
}
// Whether or not the config changed: if nginx is down (the boot race, or
// it died on an address that has since arrived), start it. And give it a
// restart policy so the race stops being fatal in the first place.
let script = format!(
"set -eu\nmkdir -p $(dirname {dropin})\n\
cat > {dropin} <<'EOF'\n[Service]\nRestart=on-failure\nRestartSec=5\n\
[Unit]\nStartLimitIntervalSec=300\nStartLimitBurst=10\nEOF\n\
systemctl daemon-reload\n\
if ! systemctl is-active --quiet nginx; then systemctl reset-failed nginx 2>/dev/null || true; systemctl start nginx 2>/dev/null || true; \
elif [ \"${{RELOAD:-1}}\" = 1 ]; then systemctl reload nginx 2>/dev/null || true; fi\nexit 0\n",
dropin = NGINX_RESTART_DROPIN
);
host_sudo(&["sh", "-lc", &script])
.await
.context("nginx restart policy + start")?;
Ok(changed)
}
/// The console welcome banner, embedded so the OTA can fix it on deployed
/// nodes. `/etc/profile.d/archipelago.sh` is baked by the ISO installer and
/// no OTA path touched it, so every node kept whatever its ISO generation
/// shipped — including banners that print the node's own WireGuard address
/// (10.44.0.1, present on EVERY node) as the "web ui", which is unreachable
/// off-tunnel and actively misleading after a move to a new network
/// (framework-pt, 2026-08-15). Canonical copy: scripts/welcome-banner.sh;
/// the ISO builder inlines the same content for fresh installs.
const WELCOME_BANNER_SH: &str = include_str!("../../../scripts/welcome-banner.sh");
const WELCOME_BANNER_PATH: &str = "/etc/profile.d/archipelago.sh";
async fn run_welcome_banner_sync() -> Result<bool> {
let current = tokio::fs::read_to_string(WELCOME_BANNER_PATH)
.await
.unwrap_or_default();
// Only refresh a banner the installer put there: a dev machine running
// the backend from a checkout has no business growing one in /etc.
if current.is_empty() || current == WELCOME_BANNER_SH {
return Ok(false);
}
let staged = "/var/lib/archipelago/welcome-banner.staged";
if let Some(dir) = Path::new(staged).parent() {
tokio::fs::create_dir_all(dir).await.ok();
}
tokio::fs::write(staged, WELCOME_BANNER_SH)
.await
.context("stage welcome banner")?;
let script = format!(
"set -eu\ninstall -m 0755 {staged} {dest}\nexit 0\n",
staged = staged,
dest = WELCOME_BANNER_PATH
);
host_sudo(&["sh", "-lc", &script])
.await
.context("install welcome banner")?;
Ok(true)
}
async fn run_tor_helper_sync() -> Result<bool> {
let current = tokio::fs::read_to_string(TOR_HELPER_PATH)
.await
@@ -1816,77 +1427,6 @@ mod tests {
heal_stale_web_search_block("location / { try_files $uri /index.html; }").is_none()
);
}
/// The exact ExecStart framework-pt shipped with must parse, and the
/// rewrite must preserve its listen port and forward target.
#[test]
fn static_socat_bind_is_parsed_and_rewritten_dynamically() {
let line = "ExecStart=/usr/bin/socat TCP-LISTEN:18332,bind=192.168.1.249,fork,reuseaddr TCP:127.0.0.1:8332";
let (port, target) = parse_socat_static_bind(line).expect("must parse");
assert_eq!(port, "18332");
assert_eq!(target, "127.0.0.1:8332");
let dynamic = dynamic_bind_execstart(&port, &target);
assert!(dynamic.contains("TCP-LISTEN:18332,bind=$$IP"));
assert!(dynamic.contains("TCP:127.0.0.1:8332"));
assert!(dynamic.contains("route get 1.1.1.1"));
// The heal is idempotent: its own output no longer parses as a
// static bind (bind=$$IP is not a concrete address).
assert!(parse_socat_static_bind(&dynamic).is_none());
}
/// The archi-dev-box config: one stale address (old network) beside the
/// WireGuard one. The stale listener must go — nginx refuses to START
/// while it names an address the host lacks — and the current LAN
/// address must appear.
#[test]
fn stale_https_listeners_are_retargeted_to_present_addresses() {
let cfg = "server {\n listen 80 default_server;\n listen 10.44.0.1:443 ssl;\n listen 192.168.63.240:443 ssl;\n ssl_certificate /x;\n}\n";
let present = vec!["10.44.0.1".to_string(), "192.168.1.50".to_string()];
let healed = retarget_https_listeners(cfg, &present).expect("must heal");
assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
assert!(
!healed.contains("192.168.63.240"),
"stale listener must be dropped"
);
// Untouched lines survive, and the repair is idempotent.
assert!(healed.contains("listen 80 default_server;"));
assert!(healed.contains("ssl_certificate /x;"));
assert!(retarget_https_listeners(&healed, &present).is_none());
}
#[test]
fn wildcard_only_configs_and_cgnat_are_left_alone() {
// No address-pinned listener → nothing to heal (the ISO's own config).
assert!(retarget_https_listeners(
"server {\n listen 443 ssl default_server;\n}\n",
&["192.168.1.50".to_string()]
)
.is_none());
// Tailscale CGNAT must never become an nginx listener: tailscaled
// already holds :443 there, and binding it would fail nginx outright.
assert!(is_cgnat("100.69.68.39"));
assert!(is_cgnat("100.127.255.1"));
assert!(!is_cgnat("100.128.0.1"));
assert!(!is_cgnat("192.168.1.50"));
assert!(!is_cgnat("10.44.0.1"));
}
#[test]
fn socat_units_that_need_no_heal_are_left_alone() {
// Loopback bind is intentional (Tor bootstrap forwarder) — not ours.
assert!(parse_socat_static_bind(
"ExecStart=/usr/bin/socat TCP-LISTEN:18332,bind=127.0.0.1,reuseaddr,fork SOCKS4A:127.0.0.1:x.onion:8332,socksport=9050"
)
.is_none());
// No bind at all.
assert!(parse_socat_static_bind(
"ExecStart=/usr/bin/socat TCP-LISTEN:18332,fork,reuseaddr TCP:127.0.0.1:8332"
)
.is_none());
// Not a socat line.
assert!(parse_socat_static_bind("ExecStart=/usr/bin/true").is_none());
}
}
/// Repair this node's own systemd restart policy.
@@ -1,136 +0,0 @@
//! Per-app operator override for the app gate's login requirement.
//!
//! The manifest declares each port's *default* policy (`auth: gated` = the
//! gate challenges, the new `auth: open` = the gate fronts the port but does
//! not challenge). This store holds the operator's runtime override — set
//! from Settings → app details — so a node owner can un-gate an app that
//! carries its own login (Gitea, BTCPay) or force the gate back onto an
//! `open` app, without editing manifests or waiting for a catalog re-sign.
//!
//! Lives in the same merge-preserving per-app JSON files as the version
//! preferences (`/var/lib/archipelago/app-configs/<app_id>.json`, key
//! `"gateEnabled"`). Absent key = follow the manifest default.
use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::{Map, Value};
fn config_dir() -> PathBuf {
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
PathBuf::from(base).join("app-configs")
}
fn config_path(app_id: &str) -> PathBuf {
config_dir().join(format!("{app_id}.json"))
}
fn read_raw(app_id: &str) -> Map<String, Value> {
match std::fs::read_to_string(config_path(app_id)) {
Ok(s) => serde_json::from_str::<Value>(&s)
.ok()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default(),
Err(_) => Map::new(),
}
}
/// The operator's gate override for one app. `None` = no override recorded —
/// the manifest default applies.
pub fn gate_override(app_id: &str) -> Option<bool> {
read_raw(app_id).get("gateEnabled").and_then(Value::as_bool)
}
/// Every recorded override, keyed by app id (the config file stem). Used by
/// the gate's port-map build so one directory scan covers all apps.
pub fn all_gate_overrides() -> HashMap<String, bool> {
let mut out = HashMap::new();
let Ok(entries) = std::fs::read_dir(config_dir()) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Some(v) = gate_override(app_id) {
out.insert(app_id.to_string(), v);
}
}
out
}
/// Set (`Some`) or clear (`None`) the override, preserving every other key in
/// the app's config file. Temp+rename so a crash mid-write can't truncate.
pub fn write_gate_override(app_id: &str, enabled: Option<bool>) -> std::io::Result<()> {
let path = config_path(app_id);
let mut obj = read_raw(app_id);
match enabled {
Some(v) => {
obj.insert("gateEnabled".to_string(), Value::Bool(v));
}
None => {
obj.remove("gateEnabled");
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_string_pretty(&Value::Object(obj))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serialized.as_bytes())?;
std::fs::rename(&tmp, &path)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_tmp_data_dir<T>(f: impl FnOnce() -> T) -> T {
let dir = tempfile::tempdir().expect("tempdir");
// Serialize env mutation across tests in this module.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("ARCHIPELAGO_DATA_DIR", dir.path());
let out = f();
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
out
}
#[test]
fn absent_file_means_no_override() {
with_tmp_data_dir(|| {
assert_eq!(gate_override("gitea"), None);
assert!(all_gate_overrides().is_empty());
});
}
#[test]
fn write_read_clear_roundtrip_preserves_other_keys() {
with_tmp_data_dir(|| {
// Seed an existing config with an unrelated key.
std::fs::create_dir_all(config_dir()).unwrap();
std::fs::write(config_path("gitea"), r#"{"autoUpdate": true}"#).unwrap();
write_gate_override("gitea", Some(false)).unwrap();
assert_eq!(gate_override("gitea"), Some(false));
assert_eq!(all_gate_overrides().get("gitea"), Some(&false));
// The unrelated key survives.
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
let v: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
write_gate_override("gitea", None).unwrap();
assert_eq!(gate_override("gitea"), None);
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
let v: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
});
}
}
@@ -221,12 +221,6 @@ impl BootReconciler {
}
async fn tick(&self) {
// Sweep ghost containers first: a process tree podman has forgotten
// still holds its app's ports and data locks, so reconcile would keep
// restarting that app into the same wall (752 restarts on a fleet
// node, 2026-08-10). Nothing else in the stack can see them —
// every podman-level stop/rm misses a container podman lost.
crate::container::ghost_reaper::reap_all().await;
let report = self.orchestrator.reconcile_existing().await;
Self::log_report(&report);
}
@@ -1,320 +0,0 @@
//! Ghost-container reaper.
//!
//! A *ghost* is a container whose process tree (conmon → the app's init → the
//! app) is still running while podman has no record of it — `podman ps -a`
//! does not list it, so every podman-level stop/rm/recreate misses it. They
//! are produced by a cleanup race: the exit-command runs `container cleanup
//! --rm`, the record is deleted, but conmon and the payload survive.
//!
//! A ghost is not merely untidy — it still owns the things the app needs:
//!
//! * the published host port, so the replacement container fails to start with
//! `rootlessport listen tcp 127.0.0.1:<port>: bind: address already in use`;
//! * file locks inside the app's data dir, so a container that does start dies
//! at boot (Gitea: `unable to lock level db … resource temporarily
//! unavailable` → fatal).
//!
//! `Restart=always` then re-runs the app straight back into the same wall —
//! observed at 752 restarts on a fleet node (2026-08-10) and again on the dev
//! box (2026-08-16), where the app finally disappeared from My Apps because no
//! container existed to list. Both were cleared by hand; this module is the
//! automation, because no podman-level release logic can reap a container
//! podman does not know about.
//!
//! Safety rule, and the reason this is id-based rather than name-based: a
//! process is only ever a reap candidate when its container id is **absent**
//! from `podman ps -a --no-trunc -q`. Killing by container *name* would hit
//! the live managed container, which is the opposite of the fix.
use std::collections::HashSet;
use std::time::Duration;
use tracing::{info, warn};
/// A container process tree podman has no record of.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ghost {
/// conmon's pid — killed last, so it cannot re-parent the payload.
pub conmon_pid: i32,
/// Full 64-hex container id from conmon's `-c` argument.
pub container_id: String,
/// Container name from conmon's `-n` argument, when present. This is what
/// ties a ghost to an app id for the pre-start reap.
pub name: Option<String>,
}
/// Read a process's argv from /proc, NUL-separated.
fn proc_argv(pid: i32) -> Option<Vec<String>> {
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
Some(
raw.split(|b| *b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect(),
)
}
/// Every pid currently in /proc.
fn all_pids() -> Vec<i32> {
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
entries
.flatten()
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()))
.collect()
}
/// Container ids podman currently knows about (running or stopped).
async fn podman_known_ids() -> Option<HashSet<String>> {
let out = tokio::process::Command::new("podman")
.args(["ps", "-a", "--no-trunc", "-q"])
.output()
.await
.ok()?;
if !out.status.success() {
// A failed listing must NEVER be read as "podman knows nothing" —
// that would make every running container look like a ghost and reap
// the whole node. Absent knowledge = do nothing.
warn!("ghost reaper: `podman ps` failed; skipping this pass");
return None;
}
Some(
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
)
}
/// Parse a conmon argv into (container_id, name), if it is a conmon at all.
fn parse_conmon(argv: &[String]) -> Option<(String, Option<String>)> {
let exe = argv.first()?;
if !exe.ends_with("conmon") {
return None;
}
let mut id = None;
let mut name = None;
let mut it = argv.iter().peekable();
while let Some(arg) = it.next() {
match arg.as_str() {
"-c" => {
if let Some(v) = it.peek() {
// Only a full 64-hex id counts; anything else is not a
// container id and must not drive a kill decision.
if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) {
id = Some((*v).clone());
}
}
}
"-n" => name = it.peek().map(|v| (*v).clone()),
_ => {}
}
}
Some((id?, name))
}
/// All ghost process trees on this host. Empty when podman cannot be listed
/// (fail-closed: unknown state reaps nothing).
pub async fn find_ghosts() -> Vec<Ghost> {
let Some(known) = podman_known_ids().await else {
return Vec::new();
};
let mut ghosts = Vec::new();
for pid in all_pids() {
let Some(argv) = proc_argv(pid) else { continue };
let Some((container_id, name)) = parse_conmon(&argv) else {
continue;
};
if known.contains(&container_id) {
continue;
}
ghosts.push(Ghost {
conmon_pid: pid,
container_id,
name,
});
}
ghosts
}
fn signal(pid: i32, sig: i32) {
// SAFETY: kill(2) with a pid we read from /proc; a dead pid returns ESRCH,
// which we ignore. Signals are the only way to reach a process podman has
// disowned.
unsafe {
libc::kill(pid, sig);
}
}
fn alive(pid: i32) -> bool {
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
/// Kill one ghost's process tree: the payload's process group first (so the
/// app's own init can shut its children down), then conmon.
///
/// SIGTERM first with a short grace, then SIGKILL — a ghost has already
/// out-lived its supervisor, and the Gitea case ignored SIGTERM outright.
async fn kill_ghost(ghost: &Ghost) {
// Children of conmon = the container's init (s6, tini, the app itself).
let children: Vec<i32> = all_pids()
.into_iter()
.filter(|pid| {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|s| {
// ppid is field 4, after the comm field which may itself
// contain spaces/parens — split on the last ')'.
let tail = s.rsplit_once(')')?.1;
tail.split_whitespace().nth(1)?.parse::<i32>().ok()
})
.is_some_and(|ppid| ppid == ghost.conmon_pid)
})
.collect();
for pid in children.iter().copied() {
signal(pid, libc::SIGTERM);
}
signal(ghost.conmon_pid, libc::SIGTERM);
tokio::time::sleep(Duration::from_secs(5)).await;
for pid in children.iter().copied() {
if alive(pid) {
signal(pid, libc::SIGKILL);
}
}
if alive(ghost.conmon_pid) {
signal(ghost.conmon_pid, libc::SIGKILL);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let survivors = children.iter().filter(|p| alive(**p)).count();
if survivors > 0 || alive(ghost.conmon_pid) {
warn!(
container_id = %&ghost.container_id[..12],
name = ?ghost.name,
survivors,
"ghost reaper: some processes survived SIGKILL"
);
}
}
/// Reap every ghost on the host. Returns how many trees were killed.
///
/// Call before a start/restart (so the replacement is not racing a dead
/// twin for its port and locks) and from the periodic reconcile.
pub async fn reap_all() -> usize {
reap_matching(|_| true).await
}
/// Reap only ghosts belonging to `app_id` — matched on the container name,
/// which the orchestrator sets to the app id (companions carry it as a
/// prefix, e.g. `archy-btcpay-db`).
pub async fn reap_for_app(app_id: &str) -> usize {
let app_id = app_id.to_string();
reap_matching(move |g| {
g.name.as_deref().is_some_and(|n| {
n == app_id
|| n.starts_with(&format!("{app_id}-"))
|| n.ends_with(&format!("-{app_id}"))
})
})
.await
}
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
let ghosts: Vec<Ghost> = find_ghosts()
.await
.into_iter()
.filter(|g| pred(g))
.collect();
if ghosts.is_empty() {
return 0;
}
for ghost in &ghosts {
warn!(
container_id = %&ghost.container_id[..12],
name = ?ghost.name,
conmon_pid = ghost.conmon_pid,
"ghost container found — podman has no record of it but its processes still \
hold the app's ports and data locks; reaping"
);
kill_ghost(ghost).await;
}
info!(
count = ghosts.len(),
"ghost reaper: reaped ghost containers"
);
ghosts.len()
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
const ID: &str = "8ea2fc65603a6db8d48701e26da1a18f1651e5f8b0e2dd4ec931356f4fab0081";
#[test]
fn parses_a_real_conmon_invocation() {
let a = argv(&[
"/usr/bin/conmon",
"--api-version",
"1",
"-c",
ID,
"-u",
ID,
"-n",
"gitea",
"--full-attach",
]);
let (id, name) = parse_conmon(&a).expect("conmon parsed");
assert_eq!(id, ID);
assert_eq!(name.as_deref(), Some("gitea"));
}
#[test]
fn ignores_processes_that_are_not_conmon() {
assert!(parse_conmon(&argv(&["/usr/local/bin/gitea", "web"])).is_none());
// A shell whose *arguments* mention conmon must never be parsed as one
// — the grep-based detection used by hand did exactly this.
assert!(parse_conmon(&argv(&["/bin/bash", "-c", "pgrep -af conmon"])).is_none());
}
#[test]
fn a_truncated_or_missing_id_is_not_reapable() {
assert!(
parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
);
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
}
#[test]
fn app_matching_covers_companions_but_not_unrelated_apps() {
let g = |n: &str| Ghost {
conmon_pid: 1,
container_id: ID.to_string(),
name: Some(n.to_string()),
};
let matches = |app: &str, name: &str| {
let app = app.to_string();
let gh = g(name);
gh.name.as_deref().is_some_and(|n| {
n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
})
};
assert!(matches("gitea", "gitea"));
assert!(matches("btcpay-server", "btcpay-server"));
assert!(matches("immich", "immich-postgres"));
assert!(matches("btcpay", "archy-btcpay"));
// Substring coincidences must not match.
assert!(!matches("pay", "btcpay-server"));
assert!(!matches("gitea", "gitea2"));
}
}
-2
View File
@@ -1,5 +1,4 @@
pub mod app_catalog;
pub mod app_gate_config;
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
@@ -7,7 +6,6 @@ pub mod data_manager;
pub mod dev_orchestrator;
pub mod docker_packages;
pub mod filebrowser;
pub mod ghost_reaper;
pub mod hooks;
pub mod image_policy;
pub mod image_versions;
+19 -58
View File
@@ -4,19 +4,9 @@
use anyhow::{Context, Result};
use tracing::{info, warn};
/// Parse df output into (used_bytes, usable_total_bytes, used_percent).
/// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
/// followed by used, size and avail.
///
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
/// counts but no ordinary process can ever allocate. Dividing by `size`
/// under-reports usage by about five points: on 2026-08-22 that disk was
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
/// itself prints and what the operator can actually spend.
/// Parse df output into (used_bytes, total_bytes, used_percent).
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
/// followed by a data line with two whitespace-separated numbers.
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
let data_line = stdout
.lines()
@@ -28,19 +18,11 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
.parse()
.context("parse df used")?;
// Parsed to keep the column contract explicit, then intentionally unused —
// see the note above on why raw size is the wrong denominator.
let _size: u64 = parts
let total: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
.parse()
.context("parse df size")?;
let avail: u64 = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
.parse()
.context("parse df avail")?;
let total = used.saturating_add(avail);
.context("parse df total")?;
let percent = if total > 0 {
(used as f64 / total as f64) * 100.0
@@ -62,7 +44,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
"/"
};
let output = tokio::process::Command::new("df")
.args(["--block-size=1", "--output=used,size,avail", data_path])
.args(["--block-size=1", "--output=used,size", data_path])
.output()
.await
.context("Failed to run df")?;
@@ -275,8 +257,8 @@ mod tests {
#[test]
fn test_parse_df_output_normal() {
// df --block-size=1 --output=used,size,avail : used, size, avail
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
// Simulates typical df --block-size=1 --output=used,size / output
let output = " Used Size\n 500000000000 1000000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 500_000_000_000);
assert_eq!(total, 1_000_000_000_000);
@@ -285,35 +267,16 @@ mod tests {
#[test]
fn test_parse_df_output_high_usage() {
let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
let output = " Used Size\n 900000000000 1000000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 900_000_000_000);
assert_eq!(total, 1_000_000_000_000);
assert!((percent - 90.0).abs() < 0.01);
}
/// The bug this function existed to hide: reserved blocks are counted by
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
#[test]
fn reserved_blocks_are_not_counted_as_free() {
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 1_681_459_122_176);
// Total is what can actually be written, not the raw device size.
assert_eq!(total, 1_852_040_495_104);
assert!(
total < 1_951_249_276_928,
"raw size must not be the denominator"
);
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
}
#[test]
fn test_parse_df_output_almost_full() {
let output = "Used Size Avail\n999 1000 1\n";
let output = "Used Size\n999 1000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 999);
assert_eq!(total, 1000);
@@ -322,7 +285,7 @@ mod tests {
#[test]
fn test_parse_df_output_empty_disk() {
let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
let output = "Used Size\n0 1000000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0);
assert_eq!(total, 1_000_000_000_000);
@@ -332,7 +295,7 @@ mod tests {
#[test]
fn test_parse_df_output_zero_total() {
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
let output = "Used Size Avail\n0 0 0\n";
let output = "Used Size\n0 0\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 0);
assert_eq!(total, 0);
@@ -375,23 +338,21 @@ mod tests {
#[test]
fn test_parse_df_output_extra_whitespace() {
let output = " Used Size Avail \n 123456 7890000 7766544 \n";
let output = " Used Size \n 123456 7890000 \n";
let (used, total, _) = parse_df_output(output).unwrap();
assert_eq!(used, 123456);
assert_eq!(total, 7_890_000);
assert_eq!(total, 7890000);
}
#[test]
fn test_parse_df_output_real_world_format() {
// Closer to real df output with header padding
// Real df output carries a reserved-block gap: size here is 1.8 TB but
// only 1.382 TB is available, so usable total is used + avail.
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
let output = " Used Size\n 328000000000 1800000000000\n";
let (used, total, percent) = parse_df_output(output).unwrap();
assert_eq!(used, 328_000_000_000);
assert_eq!(total, 1_710_000_000_000);
// ~19.2% against usable space, not 18.2% against the raw device.
assert!(percent > 19.0 && percent < 20.0, "got {percent}");
assert_eq!(total, 1_800_000_000_000);
// ~18.2%
assert!(percent > 18.0 && percent < 19.0);
}
#[tokio::test]
@@ -326,26 +326,6 @@ pub(crate) async fn notify_join(
.await;
match res {
Ok((resp, transport)) if resp.status().is_success() => {
// A JSON-RPC-level rejection still arrives as HTTP 200
// (the RPC layer returns errors in-band), so checking the
// status alone logged "delivered" for calls the peer had
// just rejected. Read the body: an in-band error is
// terminal — the signed payload is deterministic, so
// retrying identical bytes can never succeed.
let body = resp.text().await.unwrap_or_default();
let rpc_err = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").cloned())
.filter(|e| !e.is_null());
if let Some(err) = rpc_err {
tracing::warn!(
attempt,
transport = %transport,
error = %err,
"peer-joined notification rejected by peer — giving up (retrying identical payload cannot succeed)"
);
return;
}
tracing::info!(
attempt,
transport = %transport,
+2 -2
View File
@@ -7,6 +7,6 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
11434, 18081, 18083, 23000, 32838, 50002,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
];
+3 -9
View File
@@ -507,13 +507,7 @@ pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
#[allow(dead_code)]
pub(super) async fn handle_identity_received(
contact_id: u32,
// None = signal strength unknown at this layer (identity adverts arrive
// through the transport-agnostic channel path, which carries no phy
// stats). The periodic refresh_contacts pass fills in the real value for
// transports that report one; hardcoding 0 here made every discovery
// read as "0 dBm" — indistinguishable from a real (if implausible)
// reading and from "no radio at all" (2026-08-16).
rssi: Option<i16>,
rssi: i16,
did: &str,
ed_pubkey_hex: &str,
x25519_pubkey_hex: &str,
@@ -523,7 +517,7 @@ pub(super) async fn handle_identity_received(
info!(
contact_id,
did = %did,
rssi = ?rssi,
rssi,
"Archipelago peer discovered over mesh"
);
@@ -598,7 +592,7 @@ pub(super) async fn handle_identity_received(
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
x25519_pubkey: Some(x25519_bytes),
rssi,
rssi: Some(rssi),
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
+1 -1
View File
@@ -421,7 +421,7 @@ async fn handle_channel_payload(
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
handle_identity_received(
contact_id,
None,
0,
&did,
&ed_hex,
&x_hex,
-53
View File
@@ -531,22 +531,6 @@ pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
Ok(())
}
/// Two /dev paths refer to the same serial device if their symlink-resolved
/// targets match (e.g. `/dev/mesh-radio` vs the `/dev/ttyUSBn` it points at).
/// Paths that fail to resolve fall back to a plain string comparison.
async fn same_serial_device(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let ra = fs::canonicalize(a)
.await
.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b)
.await
.unwrap_or_else(|_| PathBuf::from(b));
ra == rb
}
pub async fn load_ignored_radio_contacts(data_dir: &Path) -> Vec<String> {
let path = data_dir.join(MESH_IGNORED_RADIO_FILE);
if !path.exists() {
@@ -1222,19 +1206,6 @@ impl MeshService {
Ok(dest_prefix)
}
/// True if `contact_id` is reachable over the mesh radio right now — the
/// same peer/twin resolution `peer_dest_prefix` performs, exposed as a
/// cheap bool so RPC handlers can gate radio-only transports (LXMF
/// native image, Reticulum resource transfer) without duplicating the
/// twin-resolution logic. A federation-only contact_id with no matching
/// radio twin returns false here — offering "resource-mesh" or native
/// image to such a peer sends it straight into `peer_dest_prefix`'s
/// "federation-only (no radio twin)" error (picture-send from a
/// federation-only contact, 2026-08-07).
pub async fn has_radio_route(&self, contact_id: u32) -> bool {
self.peer_dest_prefix(contact_id).await.is_ok()
}
/// Split an oversized wire payload into MC-framed base64 chunks and send
/// each via the mesh device. Matches the receive-side reassembly in
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
@@ -2297,30 +2268,6 @@ impl MeshService {
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;
// An operator-set RNode serial-port override (rnode-rf-settings.json)
// outranks `device_path` when the Reticulum session opens the radio.
// When a *different* device path is being configured (hot-swap, or
// "Keep As Is" on a newly detected radio), a stale override pinned to
// the old port would silently veto the choice the user just made —
// clear it so the explicit device selection wins. Same-device aliases
// (/dev/mesh-radio vs its ttyUSBn target) are left alone.
if let Some(new_path) = config.device_path.as_deref() {
let mut rf = rnode_settings::RNodeRfSettings::load(&self.data_dir).await;
if let Some(port) = rf.port.clone() {
if !same_serial_device(&port, new_path).await {
info!(
old_port = %port,
new_path = %new_path,
"Clearing stale RNode serial-port override — configured device path changed"
);
rf.port = None;
if let Err(e) = rf.save(&self.data_dir).await {
warn!("Failed to clear stale RNode port override: {e}");
}
}
}
}
let was_enabled = self.config.enabled;
let needs_session_restart = session_config_changed(&self.config, &config);
self.config = config.clone();
+7 -53
View File
@@ -294,13 +294,6 @@ struct ReticulumPeer {
/// In-memory only (a persisted value would be stale by definition) —
/// `0` after a restart until the peer re-announces.
last_advert_at: u64,
/// Signal stats of the last announce/message heard from this peer.
/// `Some` only for direct RNode (RF) receptions — the sidecar reports
/// `null` for TCP interfaces and multi-hop relays, which is exactly the
/// RF-vs-internet discriminator the UI needs (a TCP-fed mesh used to
/// surface every peer as rssi=0 and look like working RF, 2026-08-16).
last_rssi: Option<i16>,
last_snr: Option<f32>,
}
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
@@ -626,8 +619,6 @@ impl ReticulumLink {
// start conservative and let the first real event refresh it.
reachable: false,
last_advert_at: 0,
last_rssi: None,
last_snr: None,
},
);
}
@@ -863,13 +854,12 @@ impl ReticulumLink {
// which has no Reticulum analogue (always true, tracked
// elsewhere via `take_rx_encrypted`), so leave it false here.
pkc_capable: false,
// Signal stats from the last direct RNode reception (the
// sidecar reports them per announce/message; None over TCP
// or multi-hop, which is the honest answer there). Position
// stays Meshtastic-only — no Reticulum position-sharing
// convention is wired up.
rssi: p.last_rssi,
snr: p.last_snr,
// RSSI/SNR/position are Meshtastic-only for now (see the
// Meshtastic 1.8.0 backlog plan) — RNS doesn't expose
// per-packet signal quality through LXMF, and there's no
// Reticulum position-sharing convention wired up.
rssi: None,
snr: None,
lat: None,
lon: None,
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
@@ -1067,16 +1057,6 @@ impl ReticulumLink {
let announced_name =
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
// Per-announce signal stats from the sidecar: real numbers on
// a direct RNode reception, null over TCP or multi-hop. Only
// overwrite the cached value when the sidecar reports one —
// an announce relayed over TCP must not blank out the last
// real RF reading.
let rssi = ev
.get("rssi")
.and_then(Value::as_i64)
.and_then(|v| i16::try_from(v).ok());
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -1092,12 +1072,6 @@ impl ReticulumLink {
if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone();
}
if rssi.is_some() {
p.last_rssi = rssi;
}
if snr.is_some() {
p.last_snr = snr;
}
})
.or_insert_with(|| ReticulumPeer {
dest_hash: hash,
@@ -1106,8 +1080,6 @@ impl ReticulumLink {
arch_pubkey_hex,
reachable: true,
last_advert_at: now,
last_rssi: rssi,
last_snr: snr,
});
self.persist_peers();
}
@@ -1127,11 +1099,6 @@ impl ReticulumLink {
// existing entry is proof of life too: mark it reachable so a
// restart-restored (reachable=false) peer that DMs us doesn't
// stay red-dotted until its next announce.
let rssi = ev
.get("rssi")
.and_then(Value::as_i64)
.and_then(|v| i16::try_from(v).ok());
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
match self.peers.entry(source_hash) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(ReticulumPeer {
@@ -1140,20 +1107,11 @@ impl ReticulumLink {
arch_pubkey_hex: None,
reachable: true,
last_advert_at: 0,
last_rssi: rssi,
last_snr: snr,
});
self.persist_peers();
}
std::collections::hash_map::Entry::Occupied(mut e) => {
let p = e.get_mut();
p.reachable = true;
if rssi.is_some() {
p.last_rssi = rssi;
}
if snr.is_some() {
p.last_snr = snr;
}
e.get_mut().reachable = true;
}
}
@@ -1250,10 +1208,6 @@ impl ReticulumLink {
arch_pubkey_hex: None,
reachable: true,
last_advert_at: 0,
// Resource transfers ride an established Link —
// the sidecar reports no per-packet phy stats here.
last_rssi: None,
last_snr: None,
});
self.persist_peers();
}

Some files were not shown because too many files have changed in this diff Show More