Compare commits
226 Commits
v1.2.0-alp
...
dev-iso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65b039914 | ||
|
|
5bd3caf141 | ||
|
|
377195f7e0 | ||
|
|
9ea8877d20 | ||
|
|
1c82b8285e | ||
|
|
b773ba610f | ||
|
|
ff85754aa2 | ||
|
|
ccad4737de | ||
|
|
b214b2f52f | ||
|
|
c85534357e | ||
|
|
70254b1bb7 | ||
|
|
a69aef53b5 | ||
|
|
9dd7539edc | ||
|
|
11f7434866 | ||
|
|
9d437ea476 | ||
|
|
89a9f69a9b | ||
|
|
37f32f4e54 | ||
|
|
2c0d4a7393 | ||
|
|
5b186da770 | ||
|
|
08ddc73c75 | ||
|
|
0b5fb4c90b | ||
|
|
e8735b39ec | ||
|
|
25b789bd3f | ||
|
|
9b49ab6d99 | ||
|
|
cba87e2c28 | ||
|
|
48e87d0cfb | ||
|
|
09a9dbc6ca | ||
|
|
9085a7e79f | ||
|
|
d989535a9a | ||
|
|
20289c5bec | ||
|
|
d25969e2e5 | ||
|
|
cb1f252e4d | ||
|
|
39d7bd07b9 | ||
|
|
2e29a41627 | ||
|
|
840ecfaa5f | ||
|
|
b47fec7fba | ||
|
|
6be30b99fa | ||
|
|
4f90cf39cf | ||
|
|
53e62ea25b | ||
|
|
aff9e5111b | ||
|
|
cfe4a03ffb | ||
|
|
aada19754d | ||
|
|
1444bcb0c4 | ||
|
|
2c03dce947 | ||
|
|
7f03e39f58 | ||
|
|
82eeb915a3 | ||
|
|
e28de77596 | ||
|
|
2021de5cda | ||
|
|
9db55b0b34 | ||
|
|
9d38989048 | ||
|
|
782a4a62d5 | ||
|
|
24a5ed7601 | ||
|
|
eecc7e0e71 | ||
|
|
b94428a97b | ||
|
|
3bb91e90f3 | ||
|
|
56be32e55b | ||
|
|
34a476d0a1 | ||
|
|
013b724e02 | ||
|
|
f3f7b8b72f | ||
|
|
e8c80263f3 | ||
|
|
9e3c0b85ea | ||
|
|
93b2af203a | ||
|
|
0212bfdc1d | ||
|
|
c1ff912cb1 | ||
|
|
71b93548c3 | ||
|
|
69c62eb47a | ||
|
|
7183ebfa2b | ||
|
|
39857c775a | ||
|
|
f940b4562a | ||
|
|
4325c15541 | ||
|
|
127a36c5c8 | ||
|
|
b684c2972e | ||
|
|
320c9f5b19 | ||
|
|
bc5121b33f | ||
|
|
0bef26badd | ||
|
|
1ddf90ae50 | ||
|
|
ab48266353 | ||
|
|
493a659ed4 | ||
|
|
e4bdc775e4 | ||
|
|
13b832fdd3 | ||
|
|
3db9ff9216 | ||
|
|
5b60d13693 | ||
|
|
71d7d8c918 | ||
|
|
fad79ff955 | ||
|
|
732b04c9df | ||
|
|
6063ac553c | ||
|
|
bda8b38a95 | ||
|
|
9354a27909 | ||
|
|
3a31c2aa95 | ||
|
|
1eea46542e | ||
|
|
1a64b14354 | ||
|
|
f7a57b8f1f | ||
|
|
1d9fe06f97 | ||
|
|
9aaf8d4b95 | ||
|
|
ea222895be | ||
|
|
27f1b8d21b | ||
|
|
d71eae1815 | ||
|
|
3daf889f74 | ||
|
|
e96acc9023 | ||
|
|
2d47fd800e | ||
|
|
008573b6ac | ||
|
|
ae13c0dad2 | ||
|
|
fc1e763cff | ||
|
|
1f9124789f | ||
|
|
99e32b877f | ||
|
|
5af4c71ab7 | ||
|
|
059913d3dd | ||
|
|
08bb2c80d4 | ||
|
|
5c15c52113 | ||
|
|
aa78d92f7f | ||
|
|
997d9d36ff | ||
|
|
809e471e2b | ||
|
|
54451103f3 | ||
|
|
35f1aa2e13 | ||
|
|
74abbef00d | ||
|
|
5d8365f001 | ||
|
|
c16fa8013a | ||
|
|
0e0c97c203 | ||
|
|
0fe4ebc7d5 | ||
|
|
a7920de824 | ||
|
|
06d85e1d6f | ||
|
|
f5802f9ed0 | ||
|
|
028248dfd7 | ||
|
|
f5714a5b2e | ||
|
|
d37165ca52 | ||
|
|
13e4a738be | ||
|
|
01942cea95 | ||
|
|
24f86632d0 | ||
|
|
5099f6f763 | ||
|
|
bfbaa36709 | ||
|
|
ea1b1f826b | ||
|
|
77f550fb5e | ||
|
|
8e4d352393 | ||
|
|
3b35b1bee0 | ||
|
|
f3976ba03a | ||
|
|
5c3a3ffa8e | ||
|
|
2f60ef44ea | ||
|
|
3b7d541224 | ||
|
|
4d17c60da7 | ||
|
|
38dc845f57 | ||
|
|
c299199d37 | ||
|
|
b5024c29df | ||
|
|
196682f2f2 | ||
|
|
b31148a8b7 | ||
|
|
b4d204d1d6 | ||
|
|
c82158c7c8 | ||
|
|
9b6adfc42d | ||
|
|
f0a403b224 | ||
|
|
fc1120338d | ||
|
|
4c0c8a83a9 | ||
|
|
b3949fdcf7 | ||
|
|
c4853fe746 | ||
|
|
c5417640a2 | ||
|
|
1f732d8d08 | ||
|
|
867e56cb84 | ||
|
|
203b044646 | ||
|
|
d98a2512b7 | ||
|
|
93aaeb4abe | ||
|
|
12679b77b7 | ||
|
|
781cbf3263 | ||
|
|
f1d9ecc392 | ||
|
|
973beb887a | ||
|
|
cf184661d9 | ||
|
|
1a138c0409 | ||
|
|
f8794791f3 | ||
|
|
f8eefa87d2 | ||
|
|
96d722ed0f | ||
|
|
42a1526b70 | ||
|
|
86df0bcaf2 | ||
|
|
9fe680def1 | ||
|
|
9e15444228 | ||
|
|
c78a123e9c | ||
|
|
ca65a8172c | ||
|
|
f20f0650cf | ||
|
|
9b4aa712f2 | ||
|
|
e574b6dd18 | ||
|
|
6033199864 | ||
|
|
5e19a80f9d | ||
|
|
aabeb2e679 | ||
|
|
e8674a3801 | ||
|
|
ba6a0e6fe6 | ||
|
|
f292ebf63e | ||
|
|
1dfceeb957 | ||
|
|
c037db9d42 | ||
|
|
1a74a930f7 | ||
|
|
d1b48388fb | ||
|
|
8c800525c0 | ||
|
|
aad98dec08 | ||
|
|
a9bb5a28ce | ||
|
|
7cb4fd6812 | ||
|
|
75018da1da | ||
|
|
41ab499698 | ||
|
|
b8afb10ec6 | ||
|
|
165972e75c | ||
|
|
b7edada7fe | ||
|
|
a2bf51615f | ||
|
|
adcc3fddc7 | ||
|
|
7bbd8f889a | ||
|
|
12412c70db | ||
|
|
41ff1021ad | ||
|
|
00bfd62393 | ||
|
|
a6f1ab8d53 | ||
|
|
c1db74ed28 | ||
|
|
27f205f38a | ||
|
|
25ad68ac4c | ||
|
|
1ffc377a9c | ||
|
|
19ab5c0749 | ||
|
|
c080c12629 | ||
|
|
0281229425 | ||
|
|
02d9bc3e44 | ||
|
|
cb11871b03 | ||
|
|
ba82fa1564 | ||
|
|
bd5a24515f | ||
|
|
dd5ab6b10a | ||
|
|
f54206d231 | ||
|
|
9f90c2cc91 | ||
|
|
db472691c9 | ||
|
|
836290840c | ||
|
|
00eebfbb3d | ||
|
|
a6f2e6743f | ||
|
|
0c5b7db4a2 | ||
|
|
fef7e8cb24 | ||
|
|
280c61f857 | ||
|
|
3682855668 | ||
|
|
93c2c3ee67 | ||
|
|
cc8a6fd4d8 |
@@ -5,6 +5,7 @@
|
|||||||
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
- [deploy-automation.md](deploy-automation.md) — Deploy script automation TODOs (API key, AIUI nginx, swap)
|
||||||
|
|
||||||
## Servers & Deploy
|
## Servers & Deploy
|
||||||
|
- [project_environments.md](project_environments.md) — Four environments: dev mode, dev server/prod, demo
|
||||||
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
- [tailscale_servers.md](tailscale_servers.md) — Tailscale server details (archipelago-2, archipelago-3)
|
||||||
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
- [reference_tailscale_nodes.md](reference_tailscale_nodes.md) — All node IPs and SSH commands
|
||||||
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
- [second-server.md](second-server.md) — Second dev server (archipelago-2 via Tailscale)
|
||||||
@@ -27,6 +28,12 @@
|
|||||||
- [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes
|
- [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes
|
||||||
- [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes
|
- [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
- [project_bitcoin_rpc_auth.md](project_bitcoin_rpc_auth.md) — Bitcoin rpcauth, system Tor, reboot survival, container resilience
|
||||||
|
|
||||||
|
## Deploy & Container Fixes
|
||||||
|
- [project_deploy_session_2026_03_22.md](project_deploy_session_2026_03_22.md) — Fleet deploy fixes: credential mismatches, restart storms, rootless port 80, deploy script hardening
|
||||||
|
|
||||||
## Completed Work
|
## Completed Work
|
||||||
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
- [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed
|
||||||
- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed
|
- [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed
|
||||||
|
|||||||
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
21
.claude/memory/project_bitcoin_rpc_auth.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Bitcoin RPC rpcauth architecture
|
||||||
|
description: Bitcoin uses rpcauth (salted hash in config, password in secrets file), system Tor for containers, reboot survival
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Bitcoin RPC uses `rpcauth` — salted HMAC-SHA256 hash in bitcoin.conf, plaintext password in `/var/lib/archipelago/secrets/bitcoin-rpc-password`. Credentials are STABLE across reboots, restarts, deploys.
|
||||||
|
|
||||||
|
**Why:** Cookie auth rotates on every Bitcoin restart, breaking all dependent containers with env-var-only credentials. The `rpcauth` approach keeps the password stable while never exposing plaintext in config files or CLI args.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- Bitcoin: reads rpcauth from bitcoin.conf (no CLI credential flags, config generated by first-boot or deploy)
|
||||||
|
- LND: `bitcoind.rpcuser/rpcpass` in lnd.conf (NOT rpccookie — LND v0.18.4 doesn't support it)
|
||||||
|
- All containers: read password from secrets file at creation time, passed via env vars
|
||||||
|
- Rust backend `bitcoin_rpc.rs`: reads from secrets file, cached with OnceCell
|
||||||
|
- bitcoin-ui: mounts `/var/lib/archipelago/secrets:/secrets:ro`, start.sh reads password and injects nginx auth header
|
||||||
|
- System Tor: `SocksPort 0.0.0.0:9050` + SocksPolicy, containers use `host.containers.internal:9050`
|
||||||
|
- `podman-restart.service` enabled for container auto-start after reboot
|
||||||
|
- Tor hidden service hostnames copied to `/var/lib/archipelago/tor-hostnames/` for readable access
|
||||||
|
- .198 ElectrumX points at .228's full Bitcoin node (pruned node can't run ElectrumX locally)
|
||||||
|
- Health monitor interval: 60 seconds — UI may briefly show "crashed" during restarts
|
||||||
98
.claude/memory/project_deploy_session_2026_03_22.md
Normal file
98
.claude/memory/project_deploy_session_2026_03_22.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: Deploy session 2026-03-22 findings
|
||||||
|
description: Comprehensive deploy/build fixes made overnight — container issues, image tags, script improvements, remaining work
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Summary (2026-03-22 overnight)
|
||||||
|
|
||||||
|
Massive deploy infrastructure overhaul across all 5 nodes (.228, .198, Arch 1/2/3).
|
||||||
|
|
||||||
|
### Fixed in deploy-tailscale.sh
|
||||||
|
- **Image tags**: Bitcoin Knots `28.1` (not `v28.1`), BTCPay `1.13.7` (not `1.14.5`), SearXNG `2026.3.20-6c7e9c197`
|
||||||
|
- **Removed Immich** (3 containers) and **Penpot** (5 containers) from deploy + build
|
||||||
|
- **Fedimint**: `FM_REL_NOTES_ACK=0_4_xyz` env var (NOT `FM_SKIP_REL_NOTES_ACK` or `FM_REQ_RELEASE_NOTES_ACK_V0_4`)
|
||||||
|
- **Fedimint-gateway**: `--password` instead of `--bcrypt-password-hash` (v0.5.1 CLI change)
|
||||||
|
- **FileBrowser**: added `--cap-add NET_BIND_SERVICE` for port 80 binding
|
||||||
|
- **SearXNG**: added `/var/lib/archipelago/searxng:/etc/searxng` volume mount + caps
|
||||||
|
- **Postgres**: pinned to `postgres:15` (data initialized with 15, incompatible with 16)
|
||||||
|
- **Migration**: one-time flag file `/var/lib/archipelago/.rootless-migrated`
|
||||||
|
- **Recreate-if-broken pattern**: containers that exist but are stopped get deleted and recreated
|
||||||
|
- **Arch 2 hostname**: fixed from hardcoded hostname to `$TAILSCALE_ARCH2`
|
||||||
|
- **Custom UI images**: graceful skip if not available, source extracted to repo (`docker/bitcoin-ui/`, `docker/electrs-ui/`)
|
||||||
|
- **AIUI tar xattr**: silenced with `--no-xattrs` (only in deploy-tailscale.sh, NOT deploy-to-target.sh yet)
|
||||||
|
- **Nginx MIME warning**: removed `text/html` from `sub_filter_types`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `--fleet` flag in deploy-to-target.sh: deploys .228 → .198 → Arch 1/2/3
|
||||||
|
- `--both` lock fix: releases lock before recursive `--live` call
|
||||||
|
- Container verification step (Step 26b): restarts exited containers, fixes permissions, checks Tor
|
||||||
|
- IndeedHub backend stack rebuilt on .228 (7 containers)
|
||||||
|
- IndeedHub nginx patched with direct IPs (podman DNS doesn't work with nginx resolver)
|
||||||
|
|
||||||
|
### Frontend changes
|
||||||
|
- Replaced Immich with FileBrowser on Setup homescreen (`goals.ts`, `EasyHome.vue`)
|
||||||
|
- `MEMPOOL_API_IMAGE` renamed to `MEMPOOL_BACKEND_IMAGE` in image-versions.sh
|
||||||
|
- Nextcloud downgraded from 30 to 29 (one major version upgrade at a time)
|
||||||
|
|
||||||
|
### Session 2 fixes (same day)
|
||||||
|
|
||||||
|
**Critical pattern found: Container credential mismatches**
|
||||||
|
- Deploy generates random passwords stored in `secrets/`. MariaDB/Postgres only use env vars on FIRST init — subsequent restarts ignore them. Container recreation with new passwords → auth failures → crash loops.
|
||||||
|
- 50,000+ cumulative container restarts across fleet from this single root cause.
|
||||||
|
|
||||||
|
**Fixes applied to all nodes:**
|
||||||
|
1. LND: `lnd.conf` rpcpass synced from `secrets/bitcoin-rpc-password` (was hardcoded `archipelago123`)
|
||||||
|
2. MariaDB mempool: data dirs wiped + reinitialized (password mismatch unrecoverable)
|
||||||
|
3. BTCPay Postgres: `ALTER USER` to sync password with secrets
|
||||||
|
4. FileBrowser: `--user 0:0` instead of `--cap-add NET_BIND_SERVICE` (rootless port 80 fix)
|
||||||
|
5. Nextcloud: same `--user 0:0` fix
|
||||||
|
6. Tailscale container on .228: removed (2,685 restarts — unauthenticated, host already has TS)
|
||||||
|
|
||||||
|
**Deploy script fixes:**
|
||||||
|
- `deploy-tailscale.sh`: LND config always synced before start, `eval "$DB_PASSWORDS"` → safe individual reads, MariaDB password sync step, filebrowser `--user 0:0`
|
||||||
|
- `deploy-to-target.sh`: LND stale config check now compares passwords (not just cookie/localhost), filebrowser `--user 0:0`
|
||||||
|
|
||||||
|
**Rootless port 80 rule**: Containers binding port 80 MUST use `--user 0:0`. `NET_BIND_SERVICE` cap doesn't work in rootless (UID 0 → host 100000, unprivileged).
|
||||||
|
|
||||||
|
### Session 3 fixes (2026-03-22 to 2026-03-24)
|
||||||
|
|
||||||
|
**Additional container fixes applied live:**
|
||||||
|
- PhotoPrism: recreated with proper `/photoprism/storage`, `/photoprism/originals`, `/photoprism/import` volume mounts (all 3 nodes)
|
||||||
|
- Vaultwarden/Jellyfin: recreated with `--user 0:0` + health checks (Arch 1/2)
|
||||||
|
- Nextcloud: downgraded image to v29 (data initialized with v28, can't skip to v30)
|
||||||
|
- Fedimint: upgraded v0.5.1 → v0.10.0 on all Tailscale nodes
|
||||||
|
- Fedimint-gateway: bcrypt hash passed via file mount (shell escaping workaround)
|
||||||
|
- SearXNG: recreated with proper caps on Arch 2
|
||||||
|
- Arch 3 right-sized: stopped immich (3), jellyfin, vaultwarden, nbxplorer (7.3GB RAM)
|
||||||
|
|
||||||
|
**Deploy script improvements (6 commits pushed):**
|
||||||
|
1. `d37165ca` — Credential sync, health checks, rootless port binding
|
||||||
|
2. `f5714a5b` — Fleet deploy falls back to Tailscale when LAN unreachable, `--all` alias
|
||||||
|
3. `028248df` — Suppress tar xattr spam in AIUI deploy (`--no-xattrs`)
|
||||||
|
4. `f5802f9e` — Fix LND config SSH escaping, Tailscale fallback for BUILD_SOURCE
|
||||||
|
5. `06d85e1d` — Fix health check escaping for SSH heredoc (`--health-cmd 'cmd'` not `"cmd"`)
|
||||||
|
6. `a7920de8` — Correct health check endpoints (fedimint→8175, nextcloud→`/`, filebrowser→`/`)
|
||||||
|
|
||||||
|
**Health checks added to deploy-tailscale.sh:**
|
||||||
|
- 25 containers now have `--health-cmd` in deploy-tailscale.sh (was zero)
|
||||||
|
- Key corrections: fedimint checks port 8175 (UI) not 8174 (websocket), nextcloud/filebrowser check `/` not custom endpoints
|
||||||
|
|
||||||
|
**Fleet status at end of session:**
|
||||||
|
|
||||||
|
| Node | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| .228 | 36/36, 0 unhealthy, load 1.0 | Fully stable |
|
||||||
|
| Arch 1 | 25/25, 0 unhealthy, load 0.5 | Fully stable |
|
||||||
|
| Arch 2 | 25/25, 0 unhealthy, load 0.2 | Fully stable |
|
||||||
|
| Arch 3 | 24/28, 0 unhealthy, load 7.7 | Right-sized for 7.3GB RAM, Bitcoin IBD at 97.8% |
|
||||||
|
| .198 | Bitcoin chain data empty (4KB) | Needs full IBD — will take days. Not pruned. |
|
||||||
|
|
||||||
|
### Remaining for next session
|
||||||
|
- **.198**: Bitcoin doing full IBD from scratch (chain data was lost/empty). No prune flag set. Will take days.
|
||||||
|
- **Arch 3**: Bitcoin IBD was at 97.8% — check if complete, then start LND/nbxplorer
|
||||||
|
- **Tor config Python syntax errors** in deploy-to-target.sh step 33 (cosmetic, falls back to system Tor)
|
||||||
|
- **deploy-to-target.sh** still missing health checks (only deploy-tailscale.sh has them)
|
||||||
|
- **first-boot-containers.sh** needs same rootless fixes (filebrowser `--user 0:0`, credential sync)
|
||||||
|
- **Fedimint guardian setup** not done on any node — all in "Setup UI" mode
|
||||||
|
- User needs to `git pull && ./scripts/deploy-to-target.sh --all` to deploy latest fixes to Tailscale nodes
|
||||||
21
.claude/memory/project_environments.md
Normal file
21
.claude/memory/project_environments.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Four Environments
|
||||||
|
description: Clear distinction between dev mode (local mock), dev server (228), demo (Portainer), and prod (same as dev server)
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Four distinct environments — use correct terminology:
|
||||||
|
|
||||||
|
| Name | What | Where | Backend | Deploy |
|
||||||
|
|------|------|-------|---------|--------|
|
||||||
|
| **Dev mode** | Local macOS, mock backend | `localhost:8100` | `mock-backend.js` on `:5959` | `npm run dev:mock` |
|
||||||
|
| **Dev server / Prod** | Primary build/test/live server | `192.168.1.228` (+ fleet) | Real Rust backend + Podman | `deploy-to-target.sh --live` |
|
||||||
|
| **Demo** | Public demo instance | Remote server | Mock Node.js via Docker | Portainer Stacks / `docker-compose.demo.yml` |
|
||||||
|
|
||||||
|
- Dev server and prod are the SAME machine (192.168.1.228) — "prod" just means "the live deployment"
|
||||||
|
- Demo is completely separate — user deploys via Portainer UI, Claude has no SSH access
|
||||||
|
- Dev mode is local-only, no containers needed, fastest iteration
|
||||||
|
|
||||||
|
**Why:** User corrected ambiguous usage of "dev servers (prod)" — these are the same thing, not two separate environments.
|
||||||
|
|
||||||
|
**How to apply:** Always say "dev mode" for local mock, "dev server" or "prod" for 228, "demo" for the Portainer instance. Never conflate them.
|
||||||
44
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
44
.claude/memory/project_repo_cleanup_and_dev_env.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: v1.3.0 Session Status (March 20)
|
||||||
|
description: Tor management system, bug fixes, federation name sync — cloud files working both ways
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployed to .228 + .198
|
||||||
|
|
||||||
|
### What's Live
|
||||||
|
- Full Tor hidden service management (systemd path unit pattern — tor-helper.sh)
|
||||||
|
- Container doctor: system Tor preferred, archy-tor container removed
|
||||||
|
- Federation name sync: server rename pushes to peers
|
||||||
|
- Cloud files working both ways over Tor
|
||||||
|
- Arch channel local echo for sent messages
|
||||||
|
- Web5 Message button → Mesh redirect
|
||||||
|
- Node names in federation/peers
|
||||||
|
- PeerFiles header shows name + DID (not onion)
|
||||||
|
- Connected Nodes flex height
|
||||||
|
- Server name persistence (root-owned file fixed)
|
||||||
|
- Tor services UI: add from installed apps, delete, restart, auth/protocol badges
|
||||||
|
- Layout: Network Interfaces + Tor Services stack on normal screens
|
||||||
|
|
||||||
|
### Architecture: Tor Management
|
||||||
|
- Backend writes staged torrc + action file to /var/lib/archipelago/tor-config/
|
||||||
|
- systemd path unit (archipelago-tor-helper.path) triggers root-level service
|
||||||
|
- tor-helper.sh processes actions: write-torrc-and-restart, restart, delete-service, sync-hostnames
|
||||||
|
- NoNewPrivileges=yes safe — no sudo from backend
|
||||||
|
- Container doctor ensures system Tor stays running after deploys
|
||||||
|
- Web apps: port 80 on .onion → local app port; Protocol services: direct port
|
||||||
|
|
||||||
|
### Onion Addresses (current)
|
||||||
|
- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion
|
||||||
|
- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion
|
||||||
|
|
||||||
|
### Still TODO
|
||||||
|
1. **Tor channel chat** — messages via Archipelago channel need testing/polish
|
||||||
|
2. **ISO build** — update build-auto-installer-iso.sh with tor-helper, systemd units, container doctor changes
|
||||||
|
3. **Better error messaging** — when nodes are down, addresses changed, all situations
|
||||||
|
4. **File access permissions** — public (no auth), federated (full access), peer-set (specific files)
|
||||||
|
5. **Auth on Tor app access** — login before accessing app via .onion (post-beta candidate)
|
||||||
|
6. **.198 health check** — deploy health check times out on .198 (backend works, likely timing)
|
||||||
|
|
||||||
|
**Why:** Session continuity for v1.3.0 beta stabilization effort.
|
||||||
|
**How to apply:** Read at start of next session. Work on TODO items in order.
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
---
|
---
|
||||||
name: Tailscale node addresses
|
name: Node inventory and SSH access
|
||||||
description: Complete list of all Tailscale node IPs and hostnames for SSH access
|
description: Complete list of all Archipelago nodes — LAN and Tailscale IPs, SSH commands, build capabilities, deploy methods
|
||||||
type: reference
|
type: reference
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tailscale Nodes
|
|
||||||
|
|
||||||
| Name | Tailscale IP | Hostname | SSH |
|
|
||||||
|------|-------------|----------|-----|
|
|
||||||
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` |
|
|
||||||
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` |
|
|
||||||
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` |
|
|
||||||
|
|
||||||
Note: `archipelago-3.tail2b6225.ts.net` and `100.124.105.113` are the SAME machine.
|
|
||||||
|
|
||||||
## LAN Nodes
|
## LAN Nodes
|
||||||
| Name | IP | SSH |
|
| Name | IP | SSH | Notes |
|
||||||
|------|-----|-----|
|
|------|-----|-----|-------|
|
||||||
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` |
|
| Primary (.228) | 192.168.1.228 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` | Full build env, CI runner, OAuth proxy |
|
||||||
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` |
|
| Secondary (.198) | 192.168.1.198 | `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` | Full build env |
|
||||||
|
|
||||||
|
## Tailscale Nodes
|
||||||
|
| Name | Tailscale IP | Hostname | SSH | Build? |
|
||||||
|
|------|-------------|----------|-----|--------|
|
||||||
|
| Arch 1 | 100.82.97.63 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.82.97.63` | Unknown |
|
||||||
|
| Arch 2 | 100.122.84.60 | archipelago-2.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@archipelago-2.tail2b6225.ts.net` | Yes (Node, Rust, Podman) |
|
||||||
|
| Arch 3 | 100.124.105.113 | archipelago-3.tail2b6225.ts.net | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.124.105.113` | No (Podman only, copy pre-built artifacts) |
|
||||||
|
| Arch Atob | 100.113.33.31 | — | `ssh -i ~/.ssh/archipelago-deploy archipelago@100.113.33.31` | Unknown |
|
||||||
|
|
||||||
|
## Deploy Methods
|
||||||
|
- **LAN nodes (.228, .198):** `./scripts/deploy-to-target.sh --both`
|
||||||
|
- **Arch 2:** `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live`
|
||||||
|
- **Arch 3:** SCP pre-built binary + frontend tarball (no build tools). Do NOT relay through .228 — SSH directly from Mac.
|
||||||
|
- **All nodes:** Use `~/.ssh/archipelago-deploy` key
|
||||||
|
|||||||
145
.claude/plans/memoized-plotting-sifakis.md
Normal file
145
.claude/plans/memoized-plotting-sifakis.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Architecture Review — Fix Remaining Issues
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The architecture review (`docs/architecture-review.html`) identified 4 P0, 6 P1, and 6 medium-priority issues across the codebase. After research, **all 4 P0s and 4 of 6 P1s are already fixed**. This plan addresses the remaining open items that improve reliability and security during the beta freeze.
|
||||||
|
|
||||||
|
**What's already fixed:** P0-1 (health RPC), P0-2 (health checks), P0-3 (backup rollback), P0-4 (nginx protections), P1-B (rate limiter cleanup), P1-C (systemd limits), P1-E (WS reconnect), P1-F (Vue error handler), Issue 11 (session async I/O).
|
||||||
|
|
||||||
|
**What we're fixing now (4 items):**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 1: Add 10s timeout to 6 bare `client.connect()` calls — DONE
|
||||||
|
|
||||||
|
**Why:** A down Nostr relay hangs the async task indefinitely, blocking identity publishing, node discovery, and marketplace operations. Direct uptime impact.
|
||||||
|
|
||||||
|
### Files & locations
|
||||||
|
|
||||||
|
| File | Line | Function |
|
||||||
|
|------|------|----------|
|
||||||
|
| `core/archipelago/src/identity_manager.rs` | 409 | `publish_profile()` |
|
||||||
|
| `core/archipelago/src/nostr_discovery.rs` | 113 | `publish_node_revocation()` |
|
||||||
|
| `core/archipelago/src/nostr_discovery.rs` | 200 | `verify_revocation()` |
|
||||||
|
| `core/archipelago/src/nostr_discovery.rs` | 264 | `discover_archipelago_nodes()` |
|
||||||
|
| `core/archipelago/src/marketplace.rs` | 298 | `discover()` |
|
||||||
|
| `core/archipelago/src/marketplace.rs` | 406 | `publish()` |
|
||||||
|
|
||||||
|
### Pattern (from `nostr_handshake.rs:126`)
|
||||||
|
|
||||||
|
Replace each `client.connect().await;` with:
|
||||||
|
```rust
|
||||||
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||||
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `use std::time::Duration;` is imported in each file. `tracing::warn!` is already available in all three files.
|
||||||
|
|
||||||
|
### Risk: LOW — Mechanical pattern replication, no logic changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 2: Pin all crypto dependency versions exactly — DONE
|
||||||
|
|
||||||
|
**Why:** Floating versions (`"2.1"` instead of `"2.2.0"`) allow `cargo update` to silently change crypto libraries. Supply chain risk + project rules violation.
|
||||||
|
|
||||||
|
### Versions (verified from Cargo.lock)
|
||||||
|
|
||||||
|
**`core/archipelago/Cargo.toml`:**
|
||||||
|
|
||||||
|
| Line | Current | Pin to |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 44 | `sha2 = "0.10"` | `"0.10.9"` |
|
||||||
|
| 45 | `hmac = "0.12"` | `"0.12.1"` |
|
||||||
|
| 50 | `ed25519-dalek = { version = "2.1", ... }` | `version = "2.2.0"` |
|
||||||
|
| 51 | `curve25519-dalek = "4"` | `"4.1.3"` |
|
||||||
|
| 52 | `rand = "0.8"` | `"0.8.5"` |
|
||||||
|
| 69 | `argon2 = "0.5"` | `"0.5.3"` |
|
||||||
|
| 70 | `chacha20poly1305 = "0.10"` | `"0.10.1"` |
|
||||||
|
| 81 | `zeroize = { version = "1.7", ... }` | `version = "1.8.2"` |
|
||||||
|
| 92 | `hkdf = "0.12"` | `"0.12.4"` |
|
||||||
|
|
||||||
|
**`core/security/Cargo.toml`:**
|
||||||
|
|
||||||
|
| Line | Current | Pin to |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 16 | `aes-gcm = "0.10"` | `"0.10.3"` |
|
||||||
|
| 17 | `rand = "0.8"` | `"0.8.5"` |
|
||||||
|
| 19 | `zeroize = { version = "1", ... }` | `version = "1.8.2"` |
|
||||||
|
|
||||||
|
**Note:** `core/models/Cargo.toml` has `ed25519-dalek = "2.0.0"` but this crate is NOT in the workspace — it's dead code. Skip it.
|
||||||
|
|
||||||
|
### Risk: LOW — Pins to versions already resolved in Cargo.lock. No actual dependency changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 3: Pin all floating container image tags — DONE
|
||||||
|
|
||||||
|
**Why:** Floating tags (`:1`, `:7`, `:alpine`, `:main`) mean two installs a week apart get different software. Supply chain risk and a support nightmare.
|
||||||
|
|
||||||
|
### File: `scripts/image-versions.sh`
|
||||||
|
|
||||||
|
| Line | Variable | Current Tag | Action |
|
||||||
|
|------|----------|-------------|--------|
|
||||||
|
| 16 | `MARIADB_IMAGE` | `:11.4` | SSH -> get exact patch version |
|
||||||
|
| 21 | `POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||||
|
| 22 | `BTCPAY_POSTGRES_IMAGE` | `:15` | SSH -> get exact patch version |
|
||||||
|
| 25 | `HOMEASSISTANT_IMAGE` | `:2024.12` | SSH -> get exact patch version |
|
||||||
|
| 27 | `UPTIME_KUMA_IMAGE` | `:1` | SSH -> get exact patch version |
|
||||||
|
| 32 | `NEXTCLOUD_IMAGE` | `:29` | SSH -> get exact patch version |
|
||||||
|
| 34 | `ONLYOFFICE_IMAGE` | `:8.2` | SSH -> get exact patch version |
|
||||||
|
| 35 | `FILEBROWSER_IMAGE` | `:v2` | SSH -> get exact patch version |
|
||||||
|
| 36 | `NPM_IMAGE` | `:2` | SSH -> get exact patch version |
|
||||||
|
| 49 | `REDIS_IMAGE` | `:7` | SSH -> get exact patch version |
|
||||||
|
| 52 | `VALKEY_IMAGE` | `:8` | SSH -> get exact patch version |
|
||||||
|
| 60 | `INDEEDHUB_POSTGRES_IMAGE` | `:16-alpine` | SSH -> get exact patch version |
|
||||||
|
| 61 | `INDEEDHUB_REDIS_IMAGE` | `:7-alpine` | SSH -> get exact patch version |
|
||||||
|
| 64 | `DWN_SERVER_IMAGE` | `:main` | SSH -> get image digest, pin by SHA or tag |
|
||||||
|
| 68 | `NGINX_ALPINE_IMAGE` | `:alpine` | SSH -> get exact version |
|
||||||
|
|
||||||
|
### Pre-work required
|
||||||
|
Run on 192.168.1.228: `podman images --format '{{.Repository}}:{{.Tag}}'` to get exact versions currently deployed. Pin to THOSE — don't upgrade.
|
||||||
|
|
||||||
|
### Risk: MEDIUM — Must match what's actually running. Wrong pin = containers fail on next creation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 4: Add CI pipeline for Rust + frontend checks — DONE
|
||||||
|
|
||||||
|
**Why:** No tests or linting run in CI. Regressions from Items 1-3 (and all future beta fixes) go undetected until they hit the server.
|
||||||
|
|
||||||
|
### File to create: `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
Two parallel jobs:
|
||||||
|
1. **`rust`** (ubuntu-latest): `cargo fmt --check` -> `cargo clippy -D warnings` -> `cargo test`
|
||||||
|
2. **`frontend`** (ubuntu-latest): `npm ci` -> `npm run type-check` -> `npm test`
|
||||||
|
|
||||||
|
Trigger: push to `main` + all PRs. Reference existing `build-macos.yml` for action versions (checkout@v4, setup-node@v4 with Node 18).
|
||||||
|
|
||||||
|
### Risk: LOW — Additive only, new file, doesn't affect existing workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. **Item 1** (Nostr timeouts) — lowest risk, immediate reliability gain
|
||||||
|
2. **Item 2** (crypto pins) — batch with Item 1 for single deploy
|
||||||
|
3. **Item 3** (container image pins) — requires SSH query first
|
||||||
|
4. **Item 4** (CI) — validates everything, no deploy needed
|
||||||
|
|
||||||
|
Items 1+2 deploy together. Item 3 deploys separately (script only). Item 4 is push-only.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Items 1+2: `cargo clippy --all-targets --all-features` on dev server (zero warnings), then deploy + test identity/discovery/marketplace features
|
||||||
|
- Item 3: `source scripts/image-versions.sh` + verify all vars have exact patch versions
|
||||||
|
- Item 4: Push to branch, verify both CI jobs pass green on GitHub Actions
|
||||||
|
|
||||||
|
## Deferred (post-beta)
|
||||||
|
|
||||||
|
- Issue 6: Generate TS types from Rust (ts-rs) — new dependency
|
||||||
|
- Issue 7: Consolidate container metadata to single source — structural refactor
|
||||||
|
- Issue 8: Split deploy/ISO scripts into modules — already planned in script comments
|
||||||
|
- Issue 9: Single app manifest driving all 6+ locations — architectural change
|
||||||
|
- Issue 12: useAsyncState composable — touches 14+ views, risky during freeze
|
||||||
File diff suppressed because it is too large
Load Diff
80
.claude/plans/prancy-scribbling-pnueli.md
Normal file
80
.claude/plans/prancy-scribbling-pnueli.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Plan: Demo Seeding, Dev Environment Fix, and Developer Onboarding
|
||||||
|
|
||||||
|
## Context
|
||||||
|
After the repo cleanup (docs/scripts archived to `~/Projects/archy-archive/`), several dev scripts reference deleted files. Additionally, the demo needs better seeding for Portainer showcase, ThunderHub + Fedimint need to be visible, and a new developer needs docs to onboard.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Fix broken dev scripts
|
||||||
|
|
||||||
|
**`neode-ui/start-dev.sh`** — Remove lines 72-110 (Docker Desktop check + `start-docker-apps.sh` call). Replace with a one-liner noting mock backend handles simulation.
|
||||||
|
|
||||||
|
**`neode-ui/stop-dev.sh`** — Remove lines 66-74 (Docker container stop block calling `stop-docker-apps.sh`).
|
||||||
|
|
||||||
|
**`neode-ui/package.json`** — Remove the `prebuild` script (line 22) that references archived `../../loop-start.mp3`. File already exists at `public/assets/audio/`.
|
||||||
|
|
||||||
|
**`scripts/dev-start.sh`** — Fix option 2 (Full Stack) lines 67-84 that reference `start-docker-apps.sh`. Guard with a skip message instead of failing.
|
||||||
|
|
||||||
|
### 2. Add ThunderHub (Lightning management UI)
|
||||||
|
|
||||||
|
**Files**: mock-backend.js, Marketplace.vue, appLauncher.ts, new icon SVG
|
||||||
|
|
||||||
|
- Port: **3010** (3000 taken by Grafana)
|
||||||
|
- Docker image: `apotdevin/thunderhub:v0.13.31`
|
||||||
|
- Add to `portMappings`, `marketplaceMetadata`, `staticDevApps`, `marketplace.get()` in mock-backend.js
|
||||||
|
- Add to `getCuratedAppList()` in Marketplace.vue (after LND entry)
|
||||||
|
- Add to `recommended` tier in `getAppTier()`
|
||||||
|
- Add `'3010': 'thunderhub'` to PORT_TO_APP_ID in appLauncher.ts
|
||||||
|
- Create `neode-ui/public/assets/img/app-icons/thunderhub.svg` (Bitcoin-orange lightning bolt icon)
|
||||||
|
|
||||||
|
### 3. Improve Fedimint in demo
|
||||||
|
|
||||||
|
**mock-backend.js**:
|
||||||
|
- Add `fedimint` to `staticDevApps` (pre-installed, running, port 8175)
|
||||||
|
- Update `marketplace.get()` version from `0.4.3` → `0.10.0`
|
||||||
|
- Fix `portMappings.fedimint` from 8174 → 8175 (Guardian UI port)
|
||||||
|
|
||||||
|
### 4. Add realistic notifications
|
||||||
|
|
||||||
|
**mock-backend.js** — Replace empty `node.notifications` with 5 realistic entries: Bitcoin sync, LND channel opened, disk warning, system update, Fedimint guardian connected.
|
||||||
|
|
||||||
|
### 5. Rewrite README for developer onboarding
|
||||||
|
|
||||||
|
**`neode-ui/README.md`** — Full rewrite:
|
||||||
|
- Quick start (npm install, npm start, localhost:8100, password123)
|
||||||
|
- Architecture overview
|
||||||
|
- Dev modes (setup/onboarding/existing/boot)
|
||||||
|
- Mock backend capabilities (8 static apps, 30+ marketplace, WebSocket, FileBrowser API, Claude proxy)
|
||||||
|
- Demo deployment (docker-compose.demo.yml, Portainer, ANTHROPIC_API_KEY)
|
||||||
|
- Design system (glassmorphism classes, tokens)
|
||||||
|
- Build commands
|
||||||
|
- Remove Angular references and outdated sections
|
||||||
|
|
||||||
|
**`neode-ui/DEV-SCRIPTS.md`** — Update "Available Test Apps" section to list the 8 actual static apps, remove Docker apps references.
|
||||||
|
|
||||||
|
### 6. Verify Docker demo build
|
||||||
|
|
||||||
|
Confirm `docker-compose.demo.yml` paths still valid after cleanup:
|
||||||
|
- `demo/aiui/` exists (for Dockerfile.web COPY)
|
||||||
|
- `neode-ui/docker/nginx-demo.conf` exists
|
||||||
|
- `neode-ui/docker/docker-entrypoint.sh` exists
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
1. `neode-ui/start-dev.sh`
|
||||||
|
2. `neode-ui/stop-dev.sh`
|
||||||
|
3. `neode-ui/package.json`
|
||||||
|
4. `scripts/dev-start.sh`
|
||||||
|
5. `neode-ui/mock-backend.js`
|
||||||
|
6. `neode-ui/src/views/Marketplace.vue`
|
||||||
|
7. `neode-ui/src/stores/appLauncher.ts`
|
||||||
|
8. `neode-ui/public/assets/img/app-icons/thunderhub.svg` (new)
|
||||||
|
9. `neode-ui/README.md`
|
||||||
|
10. `neode-ui/DEV-SCRIPTS.md`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `cd neode-ui && npm start` — should start cleanly, no errors about missing scripts
|
||||||
|
2. Visit localhost:8100 → login → Dashboard shows 8 apps (bitcoin, lnd, electrs, mempool, lorabell, filebrowser, thunderhub, fedimint)
|
||||||
|
3. Marketplace shows ThunderHub in Bitcoin category
|
||||||
|
4. Notifications bell shows 3 unread
|
||||||
|
5. `npm stop` — clean shutdown, no errors
|
||||||
|
6. `docker compose -f docker-compose.demo.yml build` — builds successfully
|
||||||
243
.claude/plans/silly-wondering-flamingo.md
Normal file
243
.claude/plans/silly-wondering-flamingo.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# ISO Overhaul: Custom Minimal Base + Branding + Size Optimization
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Archipelago ISO is ~3.9GB — too large. The root cause is a ~800MB Debian Live ISO used as the boot base, plus a ~2.1GB rootfs with no `--no-install-recommends`. We're replacing the Debian Live dependency entirely with a custom debootstrap-built installer, adding full Archipelago branding to the boot chain, and stripping the rootfs. Target: sub-2GB ISO.
|
||||||
|
|
||||||
|
All work on `dev-iso` branch with its own CI workflow. Main branch stays untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: Branch + CI Setup
|
||||||
|
|
||||||
|
**Create `dev-iso` branch and separate CI workflow.**
|
||||||
|
|
||||||
|
1. Branch from current `main`
|
||||||
|
2. Create `.gitea/workflows/build-iso-dev.yml`:
|
||||||
|
- Trigger: `push: branches: [dev-iso]` + `workflow_dispatch`
|
||||||
|
- Same structure as `build-iso.yml` (131 lines) but:
|
||||||
|
- Remove "Cache Debian Live ISO" step (no longer needed)
|
||||||
|
- Add `debootstrap`, `squashfs-tools`, `isolinux`, `syslinux-common`, `mtools`, `grub-efi-amd64-bin`, `grub-pc-bin` to tool dependencies
|
||||||
|
- Output naming: `archipelago-dev-unbundled-{date}.iso`
|
||||||
|
- Keep: backend build, frontend build, type check, tests, build report
|
||||||
|
3. Push and verify CI triggers on .228 runner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- New: `.gitea/workflows/build-iso-dev.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Rootfs Size Optimizations
|
||||||
|
|
||||||
|
**Shrink rootfs.tar from ~2.1GB to ~1.5GB. Only touches the Dockerfile heredoc in Step 1 (lines 210-335).**
|
||||||
|
|
||||||
|
### 1.1 Add `--no-install-recommends`
|
||||||
|
- Line 229: `apt-get install -y` → `apt-get install -y --no-install-recommends`
|
||||||
|
- Line 269: Same for Tailscale install
|
||||||
|
- Explicitly add packages that may be needed as recommends: `fonts-liberation`, `xfonts-base` (for Chromium kiosk)
|
||||||
|
- **Saves: ~150-300MB**
|
||||||
|
|
||||||
|
### 1.2 Remove `firmware-misc-nonfree`
|
||||||
|
- Line 257: Remove `firmware-misc-nonfree` from package list
|
||||||
|
- Keep: `firmware-realtek`, `firmware-iwlwifi`, `intel-microcode`, `amd64-microcode`
|
||||||
|
- **Saves: ~50-80MB**
|
||||||
|
|
||||||
|
### 1.3 Strip docs/man/locales
|
||||||
|
- Add after line 264 (after apt-get clean):
|
||||||
|
```dockerfile
|
||||||
|
RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null; \
|
||||||
|
find /usr/share/doc -empty -delete 2>/dev/null; \
|
||||||
|
rm -rf /usr/share/man /usr/share/info /usr/share/lintian /usr/share/linda; \
|
||||||
|
find /usr/share/locale -maxdepth 1 -mindepth 1 ! -name 'en_US' ! -name 'locale.alias' -exec rm -rf {} +
|
||||||
|
```
|
||||||
|
- **Saves: ~50-80MB**
|
||||||
|
|
||||||
|
### 1.4 Remove `wget` and `htop`
|
||||||
|
- Lines 244, 246: Remove `wget` (curl covers it) and `htop` (luxury tool)
|
||||||
|
- Keep `git` (used by self-update system)
|
||||||
|
- **Saves: ~5MB** (minor but removes unnecessary surface)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Build ISO, compare rootfs.tar size
|
||||||
|
- Boot in QEMU, verify: kiosk renders, SSH works, nginx serves UI, podman runs
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `image-recipe/build-auto-installer-iso.sh` (Step 1 Dockerfile heredoc, lines 210-335)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Replace Debian Live with Custom Debootstrap Base
|
||||||
|
|
||||||
|
**The big one. Replaces Steps 2, 5, and parts of 4 and 6.**
|
||||||
|
|
||||||
|
### 2.1 New Step 2: Build Minimal Installer Environment
|
||||||
|
|
||||||
|
Replace lines 420-502 entirely. Run debootstrap inside a container to produce:
|
||||||
|
- `vmlinuz` — kernel (reused from linux-image-amd64)
|
||||||
|
- `initrd.img` — custom initramfs with ISO-mount hook
|
||||||
|
- `filesystem.squashfs` — minimal Debian root (~120-180MB)
|
||||||
|
|
||||||
|
The installer squashfs contains only what's needed to run the auto-install script:
|
||||||
|
- `debootstrap --variant=minbase --include=systemd,systemd-sysv,udev,bash,coreutils,mount,util-linux,cryptsetup,parted,dosfstools,e2fsprogs,kmod,procps,iproute2,ca-certificates,gdisk`
|
||||||
|
- Auto-login on tty1 via getty override
|
||||||
|
- systemd service that auto-starts the installer (replaces profile.d hack)
|
||||||
|
|
||||||
|
**Key: Custom initramfs hook** (`local-bottom/archipelago-mount`) that:
|
||||||
|
1. Scans `/dev/sr0`, `/dev/sd*` for a partition containing `archipelago/auto-install.sh`
|
||||||
|
2. Mounts it read-only at `/run/archiso`
|
||||||
|
3. This replaces Debian Live's `boot=live components` mechanism
|
||||||
|
|
||||||
|
### 2.2 New Step 5: Assemble ISO Directory
|
||||||
|
|
||||||
|
Replace lines 2236-2448 entirely. Much simpler — no squashfs overlay mechanism, no tools extraction (tools are in the squashfs), no profile.d manipulation.
|
||||||
|
|
||||||
|
New Step 5 just assembles the directory structure:
|
||||||
|
```
|
||||||
|
$INSTALLER_ISO/
|
||||||
|
live/
|
||||||
|
vmlinuz
|
||||||
|
initrd.img
|
||||||
|
filesystem.squashfs
|
||||||
|
boot/grub/
|
||||||
|
grub.cfg
|
||||||
|
themes/archipelago/ (Phase 3)
|
||||||
|
efi.img (built with grub-mkimage)
|
||||||
|
isolinux/
|
||||||
|
isolinux.bin
|
||||||
|
ldlinux.c32
|
||||||
|
isolinux.cfg
|
||||||
|
EFI/BOOT/
|
||||||
|
BOOTX64.EFI (built with grub-mkimage)
|
||||||
|
archipelago/
|
||||||
|
auto-install.sh
|
||||||
|
rootfs.tar
|
||||||
|
bin/archipelago
|
||||||
|
web-ui/
|
||||||
|
scripts/
|
||||||
|
container-images/ (if bundled)
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate EFI boot image with `grub-mkimage` and ISOLINUX files from the `isolinux` package. No more extracting MBR from Debian Live.
|
||||||
|
|
||||||
|
### 2.3 Updated Step 6: ISO Creation
|
||||||
|
|
||||||
|
Replace lines 2461-2511 (MBR extraction + EFI image search). Use:
|
||||||
|
- MBR: `/usr/lib/ISOLINUX/isohdpfx.bin` (from `isolinux` package)
|
||||||
|
- EFI: `boot/grub/efi.img` (built in Step 5)
|
||||||
|
- xorriso command stays the same structure
|
||||||
|
|
||||||
|
### 2.4 Update Boot Media Paths in Step 4 (auto-install.sh)
|
||||||
|
|
||||||
|
Lines 1154-1155: Add `/run/archiso` as first search path:
|
||||||
|
```bash
|
||||||
|
for dev in /run/archiso /cdrom /media/cdrom /run/live/medium /lib/live/mount/medium; do
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update lines 2326, 2377 (no longer needed — replaced by systemd service in installer squashfs).
|
||||||
|
|
||||||
|
### 2.5 Remove Debian Live cleanup from auto-install.sh
|
||||||
|
|
||||||
|
The installed system's auto-install script currently removes `live-boot`, `live-boot-initramfs-tools`, `live-config` (around line 1872). With the custom base, these packages won't exist in the rootfs, so this cleanup becomes a harmless no-op — but should be cleaned up for clarity.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Build ISO, verify size < 2GB
|
||||||
|
- Boot in QEMU (UEFI mode): verify GRUB menu → installer → full install → reboot
|
||||||
|
- Boot in QEMU (BIOS mode): verify ISOLINUX → installer → full install → reboot
|
||||||
|
- After install: SSH, web UI, kiosk, container loading all work
|
||||||
|
- Test `test-iso-qemu.sh` (may need minor path updates)
|
||||||
|
|
||||||
|
**Files modified:**
|
||||||
|
- `image-recipe/build-auto-installer-iso.sh` (Steps 2, 4, 5, 6 — major rewrite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Archipelago Boot Branding
|
||||||
|
|
||||||
|
**Custom GRUB theme, installer banner, installed system GRUB.**
|
||||||
|
|
||||||
|
### 3.1 Create GRUB Theme
|
||||||
|
|
||||||
|
New directory: `image-recipe/branding/grub-theme/`
|
||||||
|
- `theme.txt` — dark background (#0a0a0a), white text, Bitcoin orange (#f7931a) highlight
|
||||||
|
- `background.png` — 1920x1080 dark with subtle Archipelago logo watermark
|
||||||
|
- Font files (`.pf2`) — generated with `grub-mkfont` from DejaVu Sans during build
|
||||||
|
|
||||||
|
GRUB menu entries:
|
||||||
|
- "Install Archipelago" (default, quiet boot)
|
||||||
|
- "Install Archipelago (verbose)" (no `quiet`, for debugging)
|
||||||
|
- "Boot from local disk" (chainloader)
|
||||||
|
|
||||||
|
### 3.2 Create ISOLINUX Theme
|
||||||
|
|
||||||
|
New file: `image-recipe/branding/isolinux.cfg`
|
||||||
|
- Matching dark theme for legacy BIOS boot
|
||||||
|
- Same menu entries as GRUB
|
||||||
|
|
||||||
|
### 3.3 Branded Installer Banner
|
||||||
|
|
||||||
|
The systemd service's start script displays:
|
||||||
|
```
|
||||||
|
ARCHIPELAGO BITCOIN NODE OS
|
||||||
|
Automatic Installer v0.1.0
|
||||||
|
|
||||||
|
Press Enter to start installation...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Install GRUB Theme to Target System
|
||||||
|
|
||||||
|
In Step 4 (auto-install.sh), before `update-grub` (around line 1888):
|
||||||
|
- Copy GRUB theme from ISO to `/mnt/target/boot/grub/themes/archipelago/`
|
||||||
|
- Add `GRUB_THEME="/boot/grub/themes/archipelago/theme.txt"` to `/mnt/target/etc/default/grub`
|
||||||
|
- The installed system boots with Archipelago branding, not Debian default
|
||||||
|
|
||||||
|
### 3.5 Create Background Image
|
||||||
|
|
||||||
|
Render from existing SVG favicon (`neode-ui/public/assets/icon/favico-black-v2.svg`) to PNG at appropriate sizes. Dark background with subtle centered logo.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Boot ISO: GRUB shows Archipelago theme (dark + orange)
|
||||||
|
- No Debian branding visible anywhere
|
||||||
|
- After install: target system GRUB also shows Archipelago theme
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- New: `image-recipe/branding/grub-theme/theme.txt`
|
||||||
|
- New: `image-recipe/branding/grub-theme/background.png`
|
||||||
|
- New: `image-recipe/branding/isolinux.cfg`
|
||||||
|
- Modified: `image-recipe/build-auto-installer-iso.sh` (Steps 5, 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Areas
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Custom initramfs fails to find USB media | High | Test multiple USB controller types in QEMU; add verbose fallback boot option |
|
||||||
|
| Missing packages in minbase break install | Medium | Trace auto-install.sh dependencies; test full install flow |
|
||||||
|
| GRUB EFI image missing modules | High | Include all common modules in grub-mkimage; test UEFI + BIOS |
|
||||||
|
| Kiosk breaks without recommends | Medium | Explicitly add Chromium/X11 font deps; test kiosk before merge |
|
||||||
|
| initramfs overlayfs mount fails | High | Follow well-established patterns from Arch/Ubuntu live ISOs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Phase 0** — branch + CI (~1 hour)
|
||||||
|
2. **Phase 1** — rootfs size opts (~2 hours, push + verify)
|
||||||
|
3. **Phase 2** — custom base (~8-10 hours, iterative QEMU testing)
|
||||||
|
4. **Phase 3** — branding (~3 hours)
|
||||||
|
|
||||||
|
Phases are sequential — each builds on the previous. Push after each phase, verify CI passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `image-recipe/build-auto-installer-iso.sh` | Main build script — most changes here |
|
||||||
|
| `.gitea/workflows/build-iso-dev.yml` | New CI workflow for dev-iso branch |
|
||||||
|
| `image-recipe/branding/grub-theme/*` | New GRUB theme assets |
|
||||||
|
| `image-recipe/branding/isolinux.cfg` | New ISOLINUX config |
|
||||||
|
| `image-recipe/test-iso-qemu.sh` | QEMU test script (minor updates) |
|
||||||
|
| `.gitea/workflows/build-iso.yml` | Reference for new CI workflow |
|
||||||
|
| `scripts/image-versions.sh` | Unchanged — container image versions |
|
||||||
119
.claude/plans/tailscale-migration.md
Normal file
119
.claude/plans/tailscale-migration.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Plan: Seamless Tailscale Migration for Alpha Testers
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Tailscale nodes (Arch 1/2/3) are alpha tester machines. They need full deployment — binary, frontend, infrastructure, and containers — with zero friction. Currently `deploy-tailscale.sh` only deploys binary + frontend (85 lines), missing ALL infrastructure that `deploy-to-target.sh --live` provides (rootless prereqs, UID mapping, containers, nginx, Tor, HTTPS, dev mode, UFW, etc.).
|
||||||
|
|
||||||
|
These nodes may also have old **rootful** containers that need migrating to rootless.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Don't refactor the 1615-line deploy-to-target.sh** — too risky during beta freeze. Instead:
|
||||||
|
|
||||||
|
1. **Rewrite `deploy-tailscale.sh`** as a full-deploy script with split-mode SSH resilience
|
||||||
|
2. **Add `--tailscale` flag** to `deploy-to-target.sh` as a convenience wrapper
|
||||||
|
3. **Add rootful→rootless migration** as an automatic pre-step
|
||||||
|
4. **Fix `first-boot-containers.sh`** for rootless (separate concern, for ISO builds)
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Rewrite `scripts/deploy-tailscale.sh` (~400 lines)
|
||||||
|
|
||||||
|
Currently 85 lines doing only binary+frontend. Rewrite to be a full deploy for any node, using split-mode SSH (each step = separate short SSH session) for Tailscale stability.
|
||||||
|
|
||||||
|
**Steps the new script will run (each as its own SSH session):**
|
||||||
|
|
||||||
|
1. SSH connectivity check
|
||||||
|
2. Install prerequisites (rsync, node, npm) if missing
|
||||||
|
3. Rsync code to target
|
||||||
|
4. **Rootful→rootless migration** (detect `sudo podman ps -a`, stop & remove old rootful containers)
|
||||||
|
5. Build frontend (nohup + poll, or skip if copy-only node)
|
||||||
|
6. Build backend (nohup + poll, or skip if copy-only node)
|
||||||
|
7. Create rollback backup
|
||||||
|
8. Deploy binary (build locally or copy from .228)
|
||||||
|
9. Deploy frontend (build locally or copy from .228)
|
||||||
|
10. Deploy AIUI
|
||||||
|
11. Sync nginx config + HTTPS snippets
|
||||||
|
12. Sync systemd service
|
||||||
|
13. **Setup rootless prereqs** (sysctl, linger, podman.socket)
|
||||||
|
14. **Create data dirs + UID mapping** (full chown table from deploy-to-target.sh:670-689)
|
||||||
|
15. **Dev mode** (ARCHIPELAGO_DEV_MODE=true for HTTP cookies over Tailscale)
|
||||||
|
16. Deploy nostr-provider.js
|
||||||
|
17. Deploy Claude API proxy (if ANTHROPIC_API_KEY available)
|
||||||
|
18. Setup NTP + swap
|
||||||
|
19. Restart services
|
||||||
|
20. **Setup HTTPS** (with node's own IP in SAN)
|
||||||
|
21. **Read Bitcoin RPC credentials** from server secrets
|
||||||
|
22. **Create all containers** (Bitcoin, Mempool, BTCPay, ElectrumX, LND, Fedimint, Immich, HA, Grafana, Jellyfin, Vaultwarden, SearXNG, FileBrowser)
|
||||||
|
23. **Setup Tor** hidden services
|
||||||
|
24. **Fix UFW** forward policy
|
||||||
|
25. **Fix IndeedHub** NIP-07 (if running)
|
||||||
|
26. **Transfer custom images** for copy-only nodes (individual tarballs, never combined)
|
||||||
|
27. Run container doctor
|
||||||
|
28. Write deploy manifest
|
||||||
|
29. Post-deploy health check
|
||||||
|
|
||||||
|
**Copy-only mode**: When target can't build (Arch 1/3), script detects no `cargo`/`npm` on target and copies pre-built artifacts from .228 via SSH pipe.
|
||||||
|
|
||||||
|
**Key sections to port from deploy-to-target.sh:**
|
||||||
|
- Lines 646-689 — rootless prereqs + UID mapping
|
||||||
|
- Lines 629-641 — dev mode
|
||||||
|
- Lines 839-1474 — all container creation
|
||||||
|
- Lines 1143-1234 — Tor setup
|
||||||
|
- Lines 1477-1485 — UFW fix
|
||||||
|
- Lines 1487-1545 — IndeedHub NIP-07
|
||||||
|
|
||||||
|
### 2. Add `--tailscale` flag to `deploy-to-target.sh` (~30 lines)
|
||||||
|
|
||||||
|
Wrapper that calls `deploy-tailscale.sh` for each node sequentially. Also add `--tailscale-node=arch1|arch2|arch3` for single-node targeting.
|
||||||
|
|
||||||
|
### 3. Rootful→rootless migration (in deploy-tailscale.sh step 4)
|
||||||
|
|
||||||
|
Auto-detect and handle:
|
||||||
|
```
|
||||||
|
ssh TARGET 'ROOTFUL=$(sudo podman ps -a 2>/dev/null | wc -l); if [ $ROOTFUL -gt 1 ]; then sudo podman stop --all; sudo podman rm --all; fi'
|
||||||
|
```
|
||||||
|
Data safe — `/var/lib/archipelago/` never deleted, only ownership fixed by UID mapping step.
|
||||||
|
|
||||||
|
### 4. Fix `scripts/first-boot-containers.sh` (5 targeted edits)
|
||||||
|
|
||||||
|
- **Line 15**: Change root check → archipelago user check (UID 1000)
|
||||||
|
- **Line 140**: Change `10.88.0.0/16` → `0.0.0.0/0` (match deploy-to-target.sh)
|
||||||
|
- **After line 111**: Add rootless prereqs (sysctl, linger, podman.socket)
|
||||||
|
- **After line 113**: Add full UID mapping block
|
||||||
|
- **Pin `:latest` tags**: photoprism, ollama, searxng, nginx-proxy-manager, penpot
|
||||||
|
|
||||||
|
### 5. Update `scripts/setup-https-dev.sh`
|
||||||
|
|
||||||
|
Dynamic SAN — detect node's own IPs (including Tailscale interface) instead of hardcoding .228/.198.
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change | ~Lines |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `scripts/deploy-tailscale.sh` | Full rewrite — complete deploy with split-mode SSH | ~400 |
|
||||||
|
| `scripts/deploy-to-target.sh` | Add `--tailscale` / `--tailscale-node` flags | ~30 |
|
||||||
|
| `scripts/first-boot-containers.sh` | Fix for rootless (subnet, UID mapping, prereqs) | ~40 |
|
||||||
|
| `scripts/setup-https-dev.sh` | Dynamic SAN with Tailscale IPs | ~15 |
|
||||||
|
| `docs/BETA-PROGRESS.md` | Update TASK-11 status | ~5 |
|
||||||
|
|
||||||
|
## Auth State Preservation
|
||||||
|
|
||||||
|
All user state in `/var/lib/archipelago/` is **never touched** by deploys:
|
||||||
|
- `sessions.json`, `user.json`, `identities/`, `secrets/`, `federation/`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Deploy to Arch 2 first (has build tools, safest test)
|
||||||
|
2. Then Arch 1/3 (copy-only mode)
|
||||||
|
3. For each node: `podman ps` shows containers, `curl /health` returns 200, UI loads, login works
|
||||||
|
4. Run container doctor — 0 fixes needed
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
1. Rewrite `deploy-tailscale.sh` (main deliverable)
|
||||||
|
2. Add `--tailscale` flags to `deploy-to-target.sh`
|
||||||
|
3. Fix `first-boot-containers.sh`
|
||||||
|
4. Update `setup-https-dev.sh`
|
||||||
|
5. Test: Arch 2 → Arch 1 → Arch 3
|
||||||
|
6. Update BETA-PROGRESS.md
|
||||||
@@ -5,15 +5,46 @@ globs:
|
|||||||
- "**/*podman*"
|
- "**/*podman*"
|
||||||
- "**/Containerfile"
|
- "**/Containerfile"
|
||||||
- "**/Dockerfile"
|
- "**/Dockerfile"
|
||||||
|
- "**/first-boot*"
|
||||||
|
- "**/container-doctor*"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Container Security Rules (Archipelago)
|
# Container Security Rules (Archipelago — Rootless Podman)
|
||||||
|
|
||||||
- `readonly_root: true` always — containers must not write to their root filesystem
|
## Rootless Podman Architecture
|
||||||
|
- Podman runs as `archipelago` user (UID 1000), NOT root — never use `sudo podman`
|
||||||
|
- UID namespace mapping via subuid: container UID N → host UID (100000 + N)
|
||||||
|
- Container images stored in `~/.local/share/containers/storage/` (NOT /var/lib/containers)
|
||||||
|
- Container subnet: `10.89.0.0/16` (rootless), not `10.88.0.0/16` (rootful)
|
||||||
|
- XDG_RUNTIME_DIR must be `/run/user/1000` — required for podman socket
|
||||||
|
- `loginctl enable-linger archipelago` required for containers to survive logout
|
||||||
|
|
||||||
|
## Container Security (Non-Negotiable)
|
||||||
- Drop ALL capabilities, add only what's required (`--cap-drop=ALL --cap-add=...`)
|
- Drop ALL capabilities, add only what's required (`--cap-drop=ALL --cap-add=...`)
|
||||||
- Run as non-root user (UID > 1000): `--user 1001:1001`
|
- Set `--security-opt=no-new-privileges:true` on all containers
|
||||||
- Set `--security-opt=no-new-privileges:true`
|
- Use `--read-only` + tmpfs where possible (safe apps: searxng, grafana, filebrowser, electrumx, nostr-rs-relay, ollama, indeedhub)
|
||||||
- Pin image versions by SHA256 digest, never use `:latest` tag
|
- Pin image versions — never use `:latest` tag
|
||||||
- Mount secrets as read-only files, never pass as environment variables when possible
|
- Mount secrets as read-only files, never pass as environment variables when possible
|
||||||
- Set memory and CPU limits on all containers
|
- Set memory and CPU limits on all containers
|
||||||
- Use `--network=none` unless network access is required
|
- All containers must have `--restart unless-stopped`
|
||||||
|
|
||||||
|
## Volume Ownership (Critical for Rootless)
|
||||||
|
- Volume directories must be owned by the MAPPED UID, not the container UID
|
||||||
|
- Formula: `host_uid = 100000 + container_uid`
|
||||||
|
- UID 0 (most apps) → `sudo chown -R 100000:100000 /var/lib/archipelago/{app}`
|
||||||
|
- UID 101 (bitcoin) → `sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin`
|
||||||
|
- UID 70 (postgres) → `sudo chown -R 100070:100070 /var/lib/archipelago/postgres-*`
|
||||||
|
- UID 472 (grafana) → `sudo chown -R 100472:100472 /var/lib/archipelago/grafana`
|
||||||
|
- UID 999 (mariadb) → `sudo chown -R 100999:100999 /var/lib/archipelago/mysql-*`
|
||||||
|
|
||||||
|
## Systemd Service Requirements
|
||||||
|
- `ProtectHome=no` — podman needs `~/.local/share/containers/`
|
||||||
|
- `PrivateTmp=no` — podman runtime uses `/tmp/podman-run-1000/`
|
||||||
|
- `RestrictNamespaces=` must NOT be set — rootless podman creates user namespaces
|
||||||
|
- `SystemCallFilter=` must NOT be set — rootless podman needs clone/unshare
|
||||||
|
- UFW `DEFAULT_FORWARD_POLICY="ACCEPT"` — required for LAN access to container ports
|
||||||
|
|
||||||
|
## Network Rules
|
||||||
|
- Apps needing inter-container DNS: use `--network=archy-net` (bitcoin, lnd, electrumx, mempool, btcpay, fedimint)
|
||||||
|
- Standalone apps: default bridge network
|
||||||
|
- Tailscale only: `--network=host` + `NET_ADMIN` + `NET_RAW` + `/dev/net/tun`
|
||||||
|
|||||||
@@ -1,87 +1,121 @@
|
|||||||
---
|
---
|
||||||
name: build-iso
|
name: build-iso
|
||||||
description: Build a new Archipelago auto-installer ISO image (bundled or unbundled)
|
description: Build Archipelago auto-installer ISOs. Custom debootstrap base (no Debian Live dependency), live-boot for squashfs root, hybrid BIOS+UEFI boot, Archipelago branding. Use when user says "build ISO", "build image", "create installer", or needs to work on the ISO build pipeline.
|
||||||
disable-model-invocation: true
|
allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Agent
|
||||||
allowed-tools: Bash, Read
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Build a new Archipelago auto-installer ISO.
|
# Build Archipelago ISO
|
||||||
|
|
||||||
## Pre-build checklist
|
## Architecture (dev-iso branch)
|
||||||
|
|
||||||
1. Latest code deployed to server (`/deploy` first)
|
Custom debootstrap-based installer. NO Debian Live ISO download.
|
||||||
2. System configs synced (`/sync-configs` first)
|
|
||||||
3. Everything tested and working on live server
|
|
||||||
4. Sync build scripts to server before building:
|
|
||||||
```bash
|
|
||||||
rsync -avz -e "ssh -i ~/.ssh/archipelago-deploy" \
|
|
||||||
/Users/dorian/Projects/archy/image-recipe/build-auto-installer-iso.sh \
|
|
||||||
/Users/dorian/Projects/archy/image-recipe/build-unbundled-iso.sh \
|
|
||||||
archipelago@192.168.1.228:~/archy/image-recipe/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build variants
|
| Component | Source | Size |
|
||||||
|
|-----------|--------|------|
|
||||||
|
| Installer squashfs | debootstrap --variant=minbase + live-boot | ~180MB |
|
||||||
|
| Target rootfs | Docker build (Debian bookworm, full stack) | ~1.5GB compressed |
|
||||||
|
| Kernel + initramfs | From debootstrap, with live-boot hooks | ~50MB |
|
||||||
|
| GRUB + ISOLINUX | Built from packages during Step 2 | ~1MB |
|
||||||
|
| **Total ISO** | **Unbundled** | **~2.2GB** |
|
||||||
|
|
||||||
### Unbundled ISO (recommended for distribution — ~3GB)
|
## Build Pipeline (6 Steps)
|
||||||
No pre-bundled container images. Apps install on-demand from Marketplace (requires internet).
|
|
||||||
|
**Step 1** (lines ~200-430): Build target rootfs via Docker
|
||||||
|
- Debian bookworm + all runtime packages (podman, nginx, tor, chromium, etc.)
|
||||||
|
- `--no-install-recommends` for size reduction
|
||||||
|
- Strips docs/man/locales
|
||||||
|
- Output: `archipelago-rootfs.tar` (~1.5GB)
|
||||||
|
|
||||||
|
**Step 2** (lines ~430-710): Build installer environment via debootstrap
|
||||||
|
- `debootstrap --variant=minbase` inside a container
|
||||||
|
- Installs live-boot via chroot (NOT --include — minbase can't resolve it)
|
||||||
|
- Custom initramfs with live-boot hooks
|
||||||
|
- Builds GRUB EFI image with grub-mkimage
|
||||||
|
- Creates ISOLINUX files, EFI boot image
|
||||||
|
- Installs GRUB theme + background
|
||||||
|
- Output: vmlinuz, initrd.img, filesystem.squashfs, BOOTX64.EFI, efi.img, isolinux.bin
|
||||||
|
|
||||||
|
**Step 3** (lines ~710-850): Add Archipelago components
|
||||||
|
- Backend binary, web UI, rootfs.tar, scripts, Plymouth theme
|
||||||
|
|
||||||
|
**Step 3b** (lines ~850-1230): Bundle container images (skipped if UNBUNDLED=1)
|
||||||
|
|
||||||
|
**Step 4** (lines ~1230-2380): Generate auto-install.sh
|
||||||
|
- Embedded installer script (~1100 lines)
|
||||||
|
- Disk detection, partitioning, LUKS encryption, GRUB install
|
||||||
|
- Installs GRUB + Plymouth theme on target
|
||||||
|
|
||||||
|
**Step 5** (lines ~2380-2460): Configure boot loaders
|
||||||
|
- Write GRUB config (boot=live components)
|
||||||
|
- Write ISOLINUX config
|
||||||
|
- Both reference kernel at /live/vmlinuz
|
||||||
|
|
||||||
|
**Step 6** (lines ~2460-2540): Create final ISO
|
||||||
|
- xorriso with hybrid BIOS+UEFI boot
|
||||||
|
- Uses proven MBR from `branding/isohdpfx.bin`
|
||||||
|
- `-partition_offset 16` for UEFI compatibility
|
||||||
|
|
||||||
|
## CI Workflow
|
||||||
|
|
||||||
|
**Branch**: `dev-iso` → `.gitea/workflows/build-iso-dev.yml`
|
||||||
|
**Branch**: `main` → `.gitea/workflows/build-iso.yml`
|
||||||
|
|
||||||
|
Dev CI includes a smoke test step that verifies:
|
||||||
|
- All critical files present in ISO
|
||||||
|
- Initrd contains live-boot scripts
|
||||||
|
- grub.cfg has boot=live
|
||||||
|
- Fails build before copying to Builds if any check fails
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
1. **MBR**: Always use `branding/isohdpfx.bin` (Debian Live MBR, starts with `4552`). The ISOLINUX generic MBR (`33ed`) doesn't boot on all hardware.
|
||||||
|
|
||||||
|
2. **live-boot**: Must be installed via `chroot /installer apt-get install` AFTER debootstrap completes. The `--include` flag silently fails for live-boot.
|
||||||
|
|
||||||
|
3. **Initramfs**: `update-initramfs` needs `/proc`, `/sys`, `/dev` bind-mounted in the chroot. Without them, the initramfs is broken.
|
||||||
|
|
||||||
|
4. **scripts/live is a FILE**: Verify with `[ -e ]` not `[ -d ]`.
|
||||||
|
|
||||||
|
5. **Kernel params**: Must include `boot=live components`. Without `boot=live`, live-boot hooks never activate.
|
||||||
|
|
||||||
|
6. **partition_offset 16**: Required in xorriso for UEFI firmware to recognize the USB.
|
||||||
|
|
||||||
|
7. **Never push during a running CI build**: The gitea-runner kills in-progress builds when a new commit arrives on the same branch.
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build locally (on .228):
|
||||||
|
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228
|
||||||
|
cd ~/archy/image-recipe
|
||||||
|
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
|
||||||
|
|
||||||
|
# Check build status:
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||||
'cd ~/archy/image-recipe && sudo ./build-unbundled-iso.sh'
|
"ps aux | grep build-auto | grep -v grep"
|
||||||
|
|
||||||
|
# Check latest ISO:
|
||||||
|
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
||||||
|
"ls -lt /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso | head -3"
|
||||||
|
|
||||||
|
# Verify ISO:
|
||||||
|
# See /iso-debug skill for the full verification checklist
|
||||||
|
|
||||||
|
# Iterate on branding without rebuilding:
|
||||||
|
./image-recipe/dev-branding.sh [path-to-iso]
|
||||||
|
# Or: ./scripts/dev-start.sh → option 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `results/archipelago-installer-unbundled-x86_64.iso`
|
## Key Files
|
||||||
|
|
||||||
### Full bundled ISO (~11GB)
|
| File | Role |
|
||||||
All container images pre-bundled for offline install.
|
|------|------|
|
||||||
|
| `image-recipe/build-auto-installer-iso.sh` | Main build script (~2600 lines) |
|
||||||
```bash
|
| `image-recipe/build-unbundled-iso.sh` | Wrapper: sets UNBUNDLED=1 |
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
| `image-recipe/branding/isohdpfx.bin` | Proven MBR (432 bytes) |
|
||||||
'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh'
|
| `image-recipe/branding/grub-theme/` | GRUB theme + background |
|
||||||
```
|
| `image-recipe/branding/plymouth-theme/` | Plymouth boot splash |
|
||||||
|
| `scripts/image-versions.sh` | Pinned container image versions |
|
||||||
Output: `results/archipelago-installer-x86_64.iso`
|
| `.gitea/workflows/build-iso-dev.yml` | CI for dev-iso branch |
|
||||||
|
| `image-recipe/test-iso-qemu.sh` | QEMU test script |
|
||||||
## Post-build: ALWAYS publish to FileBrowser
|
| `image-recipe/dev-branding.sh` | Quick branding iteration |
|
||||||
|
|
||||||
After EVERY successful build, copy the ISO to the FileBrowser `Builds` folder so it's downloadable from the web UI. This is mandatory — do not skip.
|
|
||||||
|
|
||||||
**FileBrowser data root**: `/var/lib/archipelago/filebrowser/`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For unbundled:
|
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
|
||||||
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
|
|
||||||
sudo cp ~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
|
|
||||||
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-unbundled-x86_64.iso'
|
|
||||||
|
|
||||||
# For bundled:
|
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
|
||||||
'sudo mkdir -p /var/lib/archipelago/filebrowser/Builds && \
|
|
||||||
sudo cp ~/archy/image-recipe/results/archipelago-installer-x86_64.iso /var/lib/archipelago/filebrowser/Builds/ && \
|
|
||||||
sudo chown 1000:1000 /var/lib/archipelago/filebrowser/Builds/archipelago-installer-x86_64.iso'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Post-build: Download to Mac (optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Unbundled:
|
|
||||||
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-unbundled-x86_64.iso ~/Downloads/
|
|
||||||
|
|
||||||
# Bundled:
|
|
||||||
scp -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-installer-x86_64.iso ~/Downloads/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key paths on server
|
|
||||||
|
|
||||||
- Build scripts: `~/archy/image-recipe/build-auto-installer-iso.sh`, `build-unbundled-iso.sh`
|
|
||||||
- Build output: `~/archy/image-recipe/results/`
|
|
||||||
- Build cache (rootfs, base ISO): `~/archy/image-recipe/build/auto-installer/`
|
|
||||||
- FileBrowser Builds: `/var/lib/archipelago/filebrowser/Builds/`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Use `--rebuild` flag to force rootfs rebuild (otherwise uses cached)
|
|
||||||
- FileBrowser container mounts `/var/lib/archipelago/filebrowser` → `/srv`
|
|
||||||
- Always `chown 1000:1000` files in FileBrowser so the app can serve them
|
|
||||||
- **IMPORTANT**: Use `build-auto-installer-iso.sh` (or `build-unbundled-iso.sh`) only. The deprecated `build-debian-iso.sh` causes boot-to-prompt issues.
|
|
||||||
|
|||||||
107
.claude/skills/design-pixel-retro/SKILL.md
Normal file
107
.claude/skills/design-pixel-retro/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
name: design-pixel-retro
|
||||||
|
description: >
|
||||||
|
Pixel Art Retro design system — ChonkyPixels font, neon glow CTAs, pixel
|
||||||
|
dot animations, and dark foundation theme. Use when building retro/pixel art
|
||||||
|
UIs, foundation sites, when user says "pixel art", "retro design", "8-bit
|
||||||
|
aesthetic", "neon glow buttons", "pixel font", or "retro foundation style".
|
||||||
|
metadata:
|
||||||
|
author: dorian
|
||||||
|
version: 1.0.0
|
||||||
|
category: design-system
|
||||||
|
tags: [pixel-art, retro, 8-bit, neon, dark-theme, foundation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pixel Art Retro Design System
|
||||||
|
|
||||||
|
Extracted from Archipelago Foundation. Pixel-perfect aesthetics with modern
|
||||||
|
web technology, neon glow accents, and playful retro energy.
|
||||||
|
|
||||||
|
## Design Identity
|
||||||
|
|
||||||
|
**Name:** Pixel Art Retro
|
||||||
|
**Mood:** Playful retro, 8-bit nostalgia with modern polish
|
||||||
|
**Background:** Dark (#0A0A0A) with pixel texture overlays
|
||||||
|
**Accent:** Bitcoin orange (#F7931A) with radial neon glow
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
```css
|
||||||
|
--font-pixel: 'ChonkyPixels', monospace; /* Display/headings — CRITICAL */
|
||||||
|
--font-body: 'Avenir Next', system-ui, sans-serif;
|
||||||
|
--font-mono: 'Courier New', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule:** ChonkyPixels must be loaded with `font-synthesis: none` and
|
||||||
|
`!important` on headings to prevent browser synthesis of bold/italic.
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
Same dark base as Glassmorphism, but with neon glow effects:
|
||||||
|
```css
|
||||||
|
--bg-primary: #0A0A0A;
|
||||||
|
--accent: #F7931A;
|
||||||
|
--accent-glow: radial-gradient(circle, rgba(247,147,26,0.4) 0%, transparent 70%);
|
||||||
|
--neon-green: #39ff14;
|
||||||
|
--neon-pink: #ff6ec7;
|
||||||
|
--neon-blue: #04d9ff;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### Neon Glow CTA
|
||||||
|
```css
|
||||||
|
.neon-cta {
|
||||||
|
background: linear-gradient(135deg, #f7931a, #e68a00);
|
||||||
|
border: 2px solid rgba(247, 147, 26, 0.5);
|
||||||
|
border-radius: 4px; /* Sharp corners — pixel aesthetic */
|
||||||
|
padding: 12px 32px;
|
||||||
|
font-family: var(--font-pixel);
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.neon-cta::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
background: var(--accent-glow);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.neon-cta:hover::after { opacity: 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pixel Dot Animation
|
||||||
|
```css
|
||||||
|
@keyframes pixel-dot-bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
.pixel-dot { animation: pixel-dot-bounce 0.6s steps(2) infinite; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intro Sequence
|
||||||
|
```css
|
||||||
|
.intro-container { animation: intro-container 0.6s ease-out; transform-origin: center; }
|
||||||
|
.intro-corners { animation: intro-corners 0.5s ease-out 0.35s both; }
|
||||||
|
.intro-logo { animation: fadeIn 0.5s ease-out 0.7s both; }
|
||||||
|
|
||||||
|
@keyframes intro-container { from { transform: scale(0.97); opacity: 0; } }
|
||||||
|
@keyframes intro-corners { from { transform: scale(0.8); opacity: 0; } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Approach
|
||||||
|
|
||||||
|
- Sharp corners (2-4px radius) — pixel aesthetic, not rounded
|
||||||
|
- Stepped animations (`steps(N)`) where possible for pixel feel
|
||||||
|
- Monospace alignment for data displays
|
||||||
|
- Donation modal: max-width 480px, QR code on white background
|
||||||
|
- Theme toggle: smooth dark/light with inverted logo filter
|
||||||
|
|
||||||
|
## Modular Architecture
|
||||||
|
|
||||||
|
- Pixel font loaded via `@font-face` with subset for performance
|
||||||
|
- Glow effects via CSS pseudo-elements (no extra DOM)
|
||||||
|
- Animation keyframes in global stylesheet
|
||||||
|
- Component-scoped overrides only
|
||||||
114
.claude/skills/gamepad-nav/SKILL.md
Normal file
114
.claude/skills/gamepad-nav/SKILL.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: gamepad-nav
|
||||||
|
description: Expert-level gamepad/controller navigation for Archipelago's console-style UI. Use when working on D-pad navigation, focus management, spatial navigation, controller support, or 10-foot UI design.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gamepad Navigation Expert
|
||||||
|
|
||||||
|
When working on gamepad/controller navigation in Archipelago, apply these console-derived patterns.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**File**: `neode-ui/src/composables/useControllerNav.ts`
|
||||||
|
**Styles**: `neode-ui/src/style.css` (focus-visible rules)
|
||||||
|
|
||||||
|
The system uses `data-` attributes for navigation zones:
|
||||||
|
- `data-controller-zone="sidebar"` / `"main"` — navigation zones
|
||||||
|
- `data-controller-container` — focusable card/group (Enter drills in, Escape exits)
|
||||||
|
- `data-controller-focusable` — marks element as focusable
|
||||||
|
- `data-controller-ignore` — excludes from navigation
|
||||||
|
- `data-controller-install` / `data-controller-launch` — app-specific actions
|
||||||
|
|
||||||
|
## Core Navigation Rules (Xbox/PS5/Switch consensus)
|
||||||
|
|
||||||
|
### D-pad Movement
|
||||||
|
- **4 directions only** — Up/Down/Left/Right, one element per press
|
||||||
|
- **Spatial navigation** — find nearest focusable in direction using bounding rect geometry
|
||||||
|
- **Distance formula**: `euclidean + displacement - alignment` with overlap scoring
|
||||||
|
- **Tiebreaker for up/down**: prefer leftmost element (visual consistency in grids)
|
||||||
|
|
||||||
|
### Wrapping
|
||||||
|
- **Linear lists (1D)**: WRAP (last to first, first to last) — sidebar menu, tab bars
|
||||||
|
- **Grids (2D)**: NO WRAP — stops at edges, prevents disorientation
|
||||||
|
|
||||||
|
### Zone Transitions
|
||||||
|
- **Right from sidebar** -> first focusable in main content (topmost)
|
||||||
|
- **Left from main's leftmost** -> sidebar's active tab (`.nav-tab-active`)
|
||||||
|
- **Focus memory**: remember last-focused element per zone, restore on re-entry
|
||||||
|
|
||||||
|
### Container Navigation
|
||||||
|
- **Enter/A**: drill into container (focus first inner element)
|
||||||
|
- **Escape/B**: exit container (focus the container itself)
|
||||||
|
- **D-pad inside container**: navigate among inner elements spatially
|
||||||
|
- **D-pad at container edge**: exit and navigate to adjacent container
|
||||||
|
|
||||||
|
### Text Input Handling
|
||||||
|
- **Up/Down arrows**: EXIT input, navigate to nearest element above/below
|
||||||
|
- **Left/Right arrows**: stay in input (cursor movement)
|
||||||
|
- **Enter**: if next focusable is a button, click it directly (submit)
|
||||||
|
- **Escape**: blur input, navigate out
|
||||||
|
|
||||||
|
### Button Mapping
|
||||||
|
| Action | Xbox | PlayStation | Switch | Keyboard |
|
||||||
|
|--------|------|------------|--------|----------|
|
||||||
|
| Confirm | A | Cross | A | Enter |
|
||||||
|
| Back | B | Circle | B | Escape |
|
||||||
|
| Navigate | D-pad | D-pad | D-pad | Arrow keys |
|
||||||
|
|
||||||
|
## Focus Visual Design
|
||||||
|
|
||||||
|
### Console standard (10-foot viewing distance)
|
||||||
|
- **Minimum 2px** border/outline (1px flickers on interlaced TVs)
|
||||||
|
- **3:1 contrast ratio** against adjacent colors (WCAG 2.4.7)
|
||||||
|
- **Smooth transitions**: 150-200ms ease-out
|
||||||
|
- **GPU compositing**: use `translateZ(0)` on animated elements
|
||||||
|
- **Never pure white** (#f1f1f1 prevents TV halo effects)
|
||||||
|
|
||||||
|
### Archipelago Focus Patterns
|
||||||
|
```css
|
||||||
|
/* Global — subtle outline that follows border-radius */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid rgba(251, 146, 60, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Containers — soft glow + slight scale */
|
||||||
|
[data-controller-container]:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
transform: scale(1.01);
|
||||||
|
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5),
|
||||||
|
0 0 20px rgba(251, 146, 60, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar items — background tint + thin ring */
|
||||||
|
.sidebar-nav-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(251, 146, 60, 0.12);
|
||||||
|
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.45);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gamepad API Integration
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
- Poll `navigator.getGamepads()` in `requestAnimationFrame` loop (cheap, returns snapshot)
|
||||||
|
- Apply deadzone: `Math.abs(axis) > 0.2` before registering input
|
||||||
|
- D-pad repeat: 400ms initial delay, 150ms interval (gamepads don't auto-repeat)
|
||||||
|
|
||||||
|
### Button indices (W3C Standard Mapping)
|
||||||
|
- 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 12=DUp, 13=DDown, 14=DLeft, 15=DRight
|
||||||
|
|
||||||
|
## When Investigating Issues
|
||||||
|
|
||||||
|
1. Check `useControllerNav.ts` for the `handleKeyDown` function
|
||||||
|
2. Check `data-controller-*` attributes in the view's template
|
||||||
|
3. Verify focusable elements are in the right `data-controller-zone`
|
||||||
|
4. Test with: arrow keys on keyboard (simulates D-pad)
|
||||||
|
5. Check `style.css` for `focus-visible` rules
|
||||||
|
|
||||||
|
## Key Sources
|
||||||
|
- [Xbox Accessibility Guideline 112](https://learn.microsoft.com/en-us/gaming/accessibility/xbox-accessibility-guidelines/112)
|
||||||
|
- [Microsoft: Gamepad and remote interactions](https://learn.microsoft.com/en-us/windows/apps/design/input/gamepad-and-remote-interactions)
|
||||||
|
- [W3C CSS Spatial Navigation](https://www.w3.org/TR/css-nav-1/)
|
||||||
|
- [W3C Gamepad Spec](https://w3c.github.io/gamepad/)
|
||||||
|
- [Norigin Spatial Navigation (React reference)](https://github.com/NoriginMedia/Norigin-Spatial-Navigation)
|
||||||
146
.claude/skills/iso-branding/SKILL.md
Normal file
146
.claude/skills/iso-branding/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
name: iso-branding
|
||||||
|
description: Design and implement Archipelago boot visuals — GRUB theme, Plymouth splash, ISOLINUX menu, console banners. Handles pixel-art cyberpunk aesthetic with Bitcoin orange accents. Use when working on boot screen design, splash animations, GRUB backgrounds, or installer UI appearance.
|
||||||
|
allowed-tools: Bash, Read, Write, Edit, Grep, Glob, Agent
|
||||||
|
---
|
||||||
|
|
||||||
|
# ISO Boot Branding — Archipelago
|
||||||
|
|
||||||
|
Design and build the visual boot experience from USB power-on to web UI.
|
||||||
|
|
||||||
|
## Brand Identity
|
||||||
|
|
||||||
|
**Archipelago** = self-sovereign Bitcoin node OS. Floating islands in the sky.
|
||||||
|
|
||||||
|
| Element | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Primary accent | `#fb923c` (Bitcoin orange) |
|
||||||
|
| Secondary accent | `#f7931a` (deeper orange) |
|
||||||
|
| Success | `#4ade80` (green) |
|
||||||
|
| Background | `#0a0a0a` → `#050505` (near-black) |
|
||||||
|
| Text | `#ffffff` (white), `#aaaaaa` (dim), `#555555` (subtle) |
|
||||||
|
| Glass | `rgba(255,255,255,0.06)` frost overlay |
|
||||||
|
| Style | Pixel art cyberpunk, dark glass morphism, CRT scanlines |
|
||||||
|
| Logo | Pixel-art lowercase "a" (from SVG favicon) |
|
||||||
|
|
||||||
|
## Boot Stages & What's Customizable
|
||||||
|
|
||||||
|
### 1. GRUB Menu (UEFI boot)
|
||||||
|
- **Background**: `branding/grub-theme/background.png` — any PNG, GRUB scales it
|
||||||
|
- **Theme**: `branding/grub-theme/theme.txt` — colors, layout, labels
|
||||||
|
- **Fonts**: Generated with `grub-mkfont` during build, .pf2 format
|
||||||
|
- **Config**: Written by build script in Step 5 (`grub.cfg` heredoc)
|
||||||
|
|
||||||
|
GRUB theme.txt properties that work:
|
||||||
|
```
|
||||||
|
desktop-color: "#rrggbb" # Fallback if no background
|
||||||
|
desktop-image: "background.png" # Background image
|
||||||
|
title-text: "" # Empty = no title
|
||||||
|
|
||||||
|
+ boot_menu {
|
||||||
|
left/top/width/height = N%
|
||||||
|
item_color = "#rrggbb"
|
||||||
|
selected_item_color = "#rrggbb"
|
||||||
|
item_height = N
|
||||||
|
item_spacing = N
|
||||||
|
scrollbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
left/top/width = N%
|
||||||
|
text = "string"
|
||||||
|
color = "#rrggbb"
|
||||||
|
align = "center"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT reference font names in theme.txt unless you know the exact internal name from grub-mkfont output. GRUB falls back to default if a font reference fails, which causes the ENTIRE theme to not load.
|
||||||
|
|
||||||
|
### 2. ISOLINUX Menu (BIOS boot)
|
||||||
|
- **Config**: Written by build script in Step 5 (`isolinux.cfg` heredoc)
|
||||||
|
- **Colors**: ANSI-style color codes in `MENU COLOR` directives
|
||||||
|
- **Title**: `MENU TITLE` string
|
||||||
|
- Text-only — no background image (use `vesamenu.c32` for graphical, but `menu.c32` is more compatible)
|
||||||
|
|
||||||
|
### 3. Plymouth Splash (kernel boot → login)
|
||||||
|
- **Theme**: `branding/plymouth-theme/archipelago.script`
|
||||||
|
- **Logo**: `branding/plymouth-theme/logo.png` (PNG with transparency)
|
||||||
|
- **Config**: `branding/plymouth-theme/archipelago.plymouth`
|
||||||
|
- Supports: animated progress bar, logo sprites, LUKS password prompt
|
||||||
|
- Kernel param `splash` must be present (added to GRUB_CMDLINE_LINUX_DEFAULT)
|
||||||
|
|
||||||
|
Plymouth script language:
|
||||||
|
```javascript
|
||||||
|
Window.SetBackgroundTopColor(r, g, b); // 0.0-1.0
|
||||||
|
logo = Image("logo.png");
|
||||||
|
sprite = Sprite(logo);
|
||||||
|
sprite.SetX(x); sprite.SetY(y);
|
||||||
|
Plymouth.SetRefreshFunction(callback);
|
||||||
|
Plymouth.SetBootProgressFunction(callback);
|
||||||
|
Plymouth.SetDisplayPasswordFunction(callback);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Console Banner (TTY login)
|
||||||
|
- ASCII art + system info in `/etc/profile.d/archipelago.sh`
|
||||||
|
- Generated in auto-install.sh (Step 4, the INSTALLER_SCRIPT heredoc)
|
||||||
|
- Uses ANSI escape codes for color
|
||||||
|
|
||||||
|
### 5. Installer Prompt
|
||||||
|
- "ARCHIPELAGO BITCOIN NODE OS / Automatic Installer"
|
||||||
|
- In the systemd service wrapper: `/usr/local/bin/archipelago-start-installer`
|
||||||
|
- Built inside the debootstrap container in Step 2
|
||||||
|
|
||||||
|
## Dev Workflow
|
||||||
|
|
||||||
|
### Quick preview (no ISO needed)
|
||||||
|
```bash
|
||||||
|
# Edit background, see it instantly:
|
||||||
|
open image-recipe/branding/grub-theme/background.png
|
||||||
|
|
||||||
|
# Generate procedural background:
|
||||||
|
python3 image-recipe/branding/generate-grub-background.py /tmp/bg.png && open /tmp/bg.png
|
||||||
|
|
||||||
|
# Generate Plymouth logo:
|
||||||
|
python3 image-recipe/branding/generate-plymouth-logo.py /tmp/logo.png && open /tmp/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full boot test (needs base ISO)
|
||||||
|
```bash
|
||||||
|
./image-recipe/dev-branding.sh [path-to-iso]
|
||||||
|
# Or via dev-start.sh option 0
|
||||||
|
```
|
||||||
|
Extracts ISO → patches branding → repackages → boots QEMU. ~30 seconds.
|
||||||
|
|
||||||
|
### What to edit
|
||||||
|
| File | Affects |
|
||||||
|
|------|---------|
|
||||||
|
| `branding/grub-theme/background.png` | GRUB boot screen image |
|
||||||
|
| `branding/grub-theme/theme.txt` | GRUB menu colors, layout |
|
||||||
|
| `branding/plymouth-theme/logo.png` | Plymouth boot logo |
|
||||||
|
| `branding/plymouth-theme/archipelago.script` | Plymouth animation/progress |
|
||||||
|
| `branding/generate-grub-background.py` | Procedural background generator |
|
||||||
|
| `branding/generate-plymouth-logo.py` | Procedural logo generator |
|
||||||
|
|
||||||
|
## Image Specs
|
||||||
|
|
||||||
|
| Asset | Format | Size | Notes |
|
||||||
|
|-------|--------|------|-------|
|
||||||
|
| GRUB background | PNG | 1024x768 recommended | GRUB scales any size, but large images slow boot |
|
||||||
|
| Plymouth logo | PNG (RGBA) | 256x256 recommended | Transparent background |
|
||||||
|
| GRUB fonts | .pf2 | Generated | `grub-mkfont -s SIZE -o out.pf2 input.ttf` |
|
||||||
|
|
||||||
|
## Build Integration
|
||||||
|
|
||||||
|
GRUB theme is installed in Step 2 (after artifacts placed):
|
||||||
|
- Static `background.png` copied from `branding/grub-theme/`
|
||||||
|
- Falls back to Python generator if static file missing
|
||||||
|
- Fonts generated in debootstrap container with `grub-mkfont`
|
||||||
|
|
||||||
|
Plymouth theme installed in Step 3 (component copy) + Step 4 (auto-install.sh):
|
||||||
|
- Files copied to `$ARCH_DIR/plymouth-theme/` in ISO
|
||||||
|
- Auto-install.sh copies to target at `/usr/share/plymouth/themes/archipelago/`
|
||||||
|
- Sets as default via `plymouth-set-default-theme`
|
||||||
|
|
||||||
|
GRUB theme also installed on TARGET system (not just installer):
|
||||||
|
- Auto-install.sh copies theme to `/mnt/target/boot/grub/themes/archipelago/`
|
||||||
|
- Adds `GRUB_THEME=` to `/mnt/target/etc/default/grub`
|
||||||
175
.claude/skills/iso-debug/SKILL.md
Normal file
175
.claude/skills/iso-debug/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: iso-debug
|
||||||
|
description: Diagnose and fix Archipelago ISO boot failures. Covers hybrid MBR/GPT, UEFI/BIOS boot chains, live-boot initramfs, GRUB/ISOLINUX configuration, xorriso packaging, and USB boot compatibility. Use when ISO doesn't boot, installer doesn't start, kernel panics, or USB isn't recognized by BIOS/UEFI.
|
||||||
|
allowed-tools: Bash, Read, Grep, Glob, Agent, Edit
|
||||||
|
---
|
||||||
|
|
||||||
|
# ISO Boot Debugging — Archipelago Custom Base
|
||||||
|
|
||||||
|
Systematic diagnosis of ISO boot failures for the Archipelago debootstrap-based installer.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The ISO boot chain has 5 stages. Failure at any stage has distinct symptoms:
|
||||||
|
|
||||||
|
| Stage | Component | Symptom if broken |
|
||||||
|
|-------|-----------|-------------------|
|
||||||
|
| 1. BIOS/UEFI recognition | Hybrid MBR + GPT | USB not in boot menu at all |
|
||||||
|
| 2. Bootloader | ISOLINUX (BIOS) or GRUB EFI (UEFI) | Black screen after selecting USB |
|
||||||
|
| 3. Kernel + initramfs | vmlinuz + initrd.img with live-boot | Kernel panic or initramfs shell |
|
||||||
|
| 4. Root filesystem | live-boot mounts filesystem.squashfs | "No root device" or blank screen |
|
||||||
|
| 5. Installer | systemd service + auto-install.sh | Boots to shell but no installer prompt |
|
||||||
|
|
||||||
|
## Stage 1: USB Not Recognized
|
||||||
|
|
||||||
|
**Most common cause**: Wrong MBR code in the ISO hybrid boot sector.
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
```bash
|
||||||
|
# Compare first 16 bytes of working vs broken ISO
|
||||||
|
xxd -l 16 working.iso
|
||||||
|
xxd -l 16 broken.iso
|
||||||
|
|
||||||
|
# Check for valid boot signature at offset 510
|
||||||
|
xxd -s 510 -l 2 broken.iso
|
||||||
|
# Must show: 55aa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known MBR codes
|
||||||
|
- `4552` — Debian Live MBR (extracted from Debian Live ISO). **Works on all tested hardware.**
|
||||||
|
- `33ed` — ISOLINUX package generic isohdpfx.bin. **Does NOT work on some UEFI hardware.**
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
The project ships the proven MBR at `image-recipe/branding/isohdpfx.bin` (432 bytes, starts with `4552`).
|
||||||
|
Build script uses it via: `-isohybrid-mbr "$SCRIPT_DIR/branding/isohdpfx.bin"`
|
||||||
|
|
||||||
|
### xorriso flags that matter
|
||||||
|
- `-isohybrid-mbr <file>` — Embeds MBR code for USB hybrid boot
|
||||||
|
- `-isohybrid-gpt-basdat` — Adds GPT partition entry for EFI (REQUIRED for UEFI USB boot)
|
||||||
|
- `-partition_offset 16` — Reserves space for GPT table (REQUIRED — without this some UEFI firmware won't see the USB)
|
||||||
|
- `-eltorito-alt-boot -e boot/grub/efi.img -no-emul-boot` — EFI boot catalog entry
|
||||||
|
|
||||||
|
### Balena Etcher
|
||||||
|
Writes raw ISO to USB — no special formatting. If the ISO boots in QEMU but not on hardware, the MBR code is the issue, not Etcher.
|
||||||
|
|
||||||
|
## Stage 2: Bootloader Failure
|
||||||
|
|
||||||
|
### BIOS path: ISOLINUX
|
||||||
|
Required files in ISO: `isolinux/isolinux.bin`, `isolinux/ldlinux.c32`, `isolinux/boot.cat`
|
||||||
|
Config: `isolinux/isolinux.cfg`
|
||||||
|
|
||||||
|
### UEFI path: GRUB
|
||||||
|
Required files: `EFI/BOOT/BOOTX64.EFI`, `boot/grub/efi.img`, `boot/grub/grub.cfg`
|
||||||
|
The EFI image is a FAT32 filesystem containing the GRUB binary, built with:
|
||||||
|
```bash
|
||||||
|
grub-mkimage -O x86_64-efi -o BOOTX64.EFI -p /boot/grub \
|
||||||
|
part_gpt part_msdos fat iso9660 udf normal boot linux search \
|
||||||
|
search_fs_uuid search_fs_file search_label configfile echo cat \
|
||||||
|
ls test true loopback gfxterm gfxmenu font png all_video video \
|
||||||
|
video_bochs video_cirrus efi_gop efi_uga
|
||||||
|
```
|
||||||
|
**Critical**: `all_video`, `efi_gop`, `efi_uga` needed for display on real hardware.
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
```bash
|
||||||
|
# Mount ISO and verify files
|
||||||
|
sudo mount -o loop,ro broken.iso /mnt
|
||||||
|
ls -la /mnt/isolinux/
|
||||||
|
ls -la /mnt/EFI/BOOT/
|
||||||
|
cat /mnt/boot/grub/grub.cfg
|
||||||
|
cat /mnt/isolinux/isolinux.cfg
|
||||||
|
sudo umount /mnt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 3: Kernel / Initramfs
|
||||||
|
|
||||||
|
### live-boot
|
||||||
|
The initramfs must contain live-boot hooks. Without them, the kernel boots but can't find root.
|
||||||
|
|
||||||
|
**Kernel params required**: `boot=live components`
|
||||||
|
- `boot=live` — triggers live-boot's initramfs scripts
|
||||||
|
- `components` — tells live-boot to scan live/ for squashfs files
|
||||||
|
|
||||||
|
### Verify initramfs has live-boot
|
||||||
|
```bash
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
unmkinitramfs /path/to/initrd.img $TMPDIR
|
||||||
|
# live-boot installs scripts/live as a FILE (not directory)
|
||||||
|
ls -la $TMPDIR/scripts/live # or $TMPDIR/main/scripts/live
|
||||||
|
file $TMPDIR/scripts/live # Should say "ASCII text"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common initramfs failures
|
||||||
|
1. **live-boot not installed**: debootstrap `--include` can't resolve its deps. Must install via `chroot apt-get` after debootstrap.
|
||||||
|
2. **Broken initramfs from container build**: `update-initramfs` needs `/proc`, `/sys`, `/dev` mounted in the chroot.
|
||||||
|
3. **scripts/live is a FILE not directory**: Verification code must use `[ -e ]` not `[ -d ]`.
|
||||||
|
|
||||||
|
## Stage 4: Root Filesystem
|
||||||
|
|
||||||
|
live-boot searches for squashfs files in `live/` on the boot media.
|
||||||
|
- Mounts boot media (USB/CDROM) at `/run/live/medium`
|
||||||
|
- Finds `live/filesystem.squashfs`
|
||||||
|
- Mounts it read-only, creates tmpfs overlay
|
||||||
|
- pivot_root into the combined root
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
If you get an initramfs shell prompt `(initramfs)`:
|
||||||
|
```bash
|
||||||
|
# Inside initramfs shell:
|
||||||
|
ls /run/live/medium/ # Is boot media mounted?
|
||||||
|
ls /run/live/medium/live/ # Is squashfs there?
|
||||||
|
cat /proc/cmdline # Does it have boot=live?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 5: Installer Not Starting
|
||||||
|
|
||||||
|
The installer auto-starts via:
|
||||||
|
1. Getty auto-login on tty1 (root, no password)
|
||||||
|
2. systemd service `archipelago-installer.service`
|
||||||
|
3. Wrapper script searches for boot media at: `/run/live/medium`, `/run/archiso`, `/cdrom`
|
||||||
|
|
||||||
|
### Diagnosis
|
||||||
|
If you get a shell but no installer prompt:
|
||||||
|
```bash
|
||||||
|
systemctl status archipelago-installer.service
|
||||||
|
cat /usr/local/bin/archipelago-start-installer
|
||||||
|
ls /run/live/medium/archipelago/auto-install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Verification Checklist
|
||||||
|
|
||||||
|
Run against any ISO before flashing:
|
||||||
|
```bash
|
||||||
|
ISO=path/to/iso
|
||||||
|
MNT=$(mktemp -d)
|
||||||
|
sudo mount -o loop,ro $ISO $MNT
|
||||||
|
|
||||||
|
echo "=== MBR ===" && xxd -l 4 $ISO
|
||||||
|
echo "=== Boot sig ===" && xxd -s 510 -l 2 $ISO
|
||||||
|
echo "=== Files ===" && for f in live/vmlinuz live/initrd.img live/filesystem.squashfs isolinux/isolinux.bin EFI/BOOT/BOOTX64.EFI boot/grub/grub.cfg archipelago/auto-install.sh; do [ -e $MNT/$f ] && echo "OK: $f" || echo "MISSING: $f"; done
|
||||||
|
echo "=== Kernel params ===" && grep "boot=live" $MNT/boot/grub/grub.cfg && echo OK || echo MISSING
|
||||||
|
echo "=== live-boot ===" && INITRD=$(mktemp -d) && unmkinitramfs $MNT/live/initrd.img $INITRD 2>/dev/null && ([ -e $INITRD/scripts/live ] && echo "OK" || echo "MISSING")
|
||||||
|
|
||||||
|
sudo umount $MNT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `image-recipe/build-auto-installer-iso.sh` | Main build script (~2600 lines) |
|
||||||
|
| `image-recipe/branding/isohdpfx.bin` | Proven MBR code (432 bytes) |
|
||||||
|
| `image-recipe/branding/grub-theme/` | GRUB theme (theme.txt + background.png) |
|
||||||
|
| `image-recipe/branding/plymouth-theme/` | Plymouth boot splash |
|
||||||
|
| `.gitea/workflows/build-iso-dev.yml` | CI workflow with smoke test |
|
||||||
|
| `image-recipe/test-iso-qemu.sh` | QEMU testing script |
|
||||||
|
| `image-recipe/dev-branding.sh` | Quick branding iteration (patch + repackage) |
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|------|-------|
|
||||||
|
| CI runner | gitea-runner.service on 192.168.1.228 |
|
||||||
|
| ISO builds | FileBrowser at http://192.168.1.228:8083 → Builds/ |
|
||||||
|
| Dev branch | dev-iso (separate CI: build-iso-dev.yml) |
|
||||||
|
| Main branch | main (CI: build-iso.yml) — DO NOT break |
|
||||||
383
.claude/skills/iso-debug/references/boot-chain-reference.md
Normal file
383
.claude/skills/iso-debug/references/boot-chain-reference.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Custom Debian ISO Boot Chain — Technical Reference
|
||||||
|
|
||||||
|
Expert reference for building and debugging custom bootable Debian-based ISOs.
|
||||||
|
Covers hybrid MBR/GPT, live-boot, debootstrap, GRUB, ISOLINUX, Plymouth, and xorriso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hybrid MBR/GPT for USB Boot
|
||||||
|
|
||||||
|
### What is isohdpfx.bin?
|
||||||
|
The first 432 bytes of a hybrid-bootable ISO. Contains the Master Boot Record code that BIOS firmware executes when booting from USB. Different sources produce different MBR code:
|
||||||
|
|
||||||
|
| Source | First bytes | Compatibility |
|
||||||
|
|--------|-------------|---------------|
|
||||||
|
| Debian Live ISO (`dd if=debian-live.iso bs=1 count=432`) | `45 52` | Best — works on all tested hardware |
|
||||||
|
| `/usr/lib/ISOLINUX/isohdpfx.bin` | `33 ed` | Generic — fails on some UEFI hardware |
|
||||||
|
| Manually built with `isohybrid` | Varies | Unpredictable |
|
||||||
|
|
||||||
|
**Rule**: Always extract MBR from a known-working ISO. Never rely on the generic ISOLINUX one.
|
||||||
|
|
||||||
|
### CRITICAL: Embedded vs Appended EFI — Real Hardware Impact
|
||||||
|
|
||||||
|
Two approaches for EFI boot in xorriso. They produce DIFFERENT hybrid structures:
|
||||||
|
|
||||||
|
| Approach | xorriso flag | cyl-align | CHS geometry | Real hardware |
|
||||||
|
|----------|-------------|-----------|--------------|---------------|
|
||||||
|
| **Embedded** | `-e boot/grub/efi.img` | `cyl-align-on` | Non-zero (e.g. 244/32) | **WORKS** |
|
||||||
|
| **Appended** | `-append_partition 2 ... -e --interval:appended_partition_2:all::` | `cyl-align-off` | `0/0` | **FAILS** |
|
||||||
|
|
||||||
|
The Will Haley guide recommends appended, but on our Dell hardware only embedded works.
|
||||||
|
Use `xorriso -indev image.iso -report_system_area plain` to check which mode an ISO uses.
|
||||||
|
|
||||||
|
### Common gotcha: installer minbase missing sudo
|
||||||
|
debootstrap --variant=minbase does NOT include sudo. If the installer runs as root
|
||||||
|
(via auto-login), do NOT use sudo in scripts. `bash: sudo: command not found` is the symptom.
|
||||||
|
|
||||||
|
### xorriso flags for hybrid boot
|
||||||
|
```bash
|
||||||
|
xorriso -as mkisofs -o output.iso \
|
||||||
|
-isohybrid-mbr isohdpfx.bin \ # Embeds MBR for BIOS USB boot
|
||||||
|
-c isolinux/boot.cat \ # El Torito boot catalog
|
||||||
|
-b isolinux/isolinux.bin \ # BIOS bootloader
|
||||||
|
-no-emul-boot -boot-load-size 4 -boot-info-table \
|
||||||
|
-eltorito-alt-boot \ # Second boot entry (EFI)
|
||||||
|
-e boot/grub/efi.img \ # EFI boot image
|
||||||
|
-no-emul-boot \
|
||||||
|
-isohybrid-gpt-basdat \ # Adds GPT partition for EFI
|
||||||
|
-partition_offset 16 \ # Space for GPT table — REQUIRED for UEFI
|
||||||
|
/path/to/iso/contents
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical flags**:
|
||||||
|
- `-isohybrid-gpt-basdat`: Without this, UEFI firmware won't see the EFI partition
|
||||||
|
- `-partition_offset 16`: Reserves 16 sectors for GPT. Without it, some UEFI firmware ignores the USB entirely
|
||||||
|
- `-isohybrid-mbr`: Without this, the ISO won't boot from USB at all (only CD-ROM)
|
||||||
|
|
||||||
|
### Balena Etcher
|
||||||
|
Writes the ISO byte-for-byte to USB — no reformatting, no special partition creation. If the ISO works with `dd`, it works with Etcher. If BIOS doesn't see the USB, the MBR code is wrong, not Etcher.
|
||||||
|
|
||||||
|
### Verifying hybrid structure
|
||||||
|
```bash
|
||||||
|
xxd -l 4 image.iso # MBR code (should be 45 52 for Debian Live)
|
||||||
|
xxd -s 510 -l 2 image.iso # Boot signature (must be 55 aa)
|
||||||
|
xxd -s 512 -l 8 image.iso # GPT signature at LBA 1 (should be "EFI PART")
|
||||||
|
file image.iso # Should say "DOS/MBR boot sector" and "bootable"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. live-boot Package
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
Provides initramfs hooks that mount a squashfs file as the root filesystem using overlayfs. This is how every Debian/Ubuntu live ISO works.
|
||||||
|
|
||||||
|
Boot flow: kernel → initramfs → live-boot scripts → find squashfs → mount overlayfs → pivot_root → systemd
|
||||||
|
|
||||||
|
### Package structure
|
||||||
|
- `live-boot` (~29KB): Main package, boot scripts
|
||||||
|
- `live-boot-initramfs-tools` (~6KB): Initramfs hooks that get baked into initrd.img
|
||||||
|
|
||||||
|
**Critical**: `scripts/live` is a **FILE**, not a directory. Verification must use `[ -e ]` not `[ -d ]`.
|
||||||
|
|
||||||
|
### Kernel parameters
|
||||||
|
| Parameter | Required | Effect |
|
||||||
|
|-----------|----------|--------|
|
||||||
|
| `boot=live` | YES | Activates live-boot's initramfs hooks |
|
||||||
|
| `components` | YES | Scans live/ for additional squashfs modules |
|
||||||
|
| `toram` | No | Copies squashfs to RAM (faster, allows USB removal) |
|
||||||
|
| `persistence` | No | Enables writable overlay on a partition labeled "persistence" |
|
||||||
|
| `quiet` | No | Suppresses boot messages |
|
||||||
|
| `splash` | No | Enables Plymouth splash screen |
|
||||||
|
| `console=ttyS0,115200` | No | Serial console for QEMU debugging |
|
||||||
|
|
||||||
|
### Where live-boot mounts things
|
||||||
|
- `/run/live/medium` — The boot media (USB/CDROM) mount point
|
||||||
|
- `/run/live/rootfs/filesystem.squashfs` — The mounted squashfs
|
||||||
|
- `/run/live/overlay` — The tmpfs overlay for writes
|
||||||
|
|
||||||
|
### Verifying live-boot in initramfs
|
||||||
|
```bash
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
unmkinitramfs /path/to/initrd.img $TMPDIR
|
||||||
|
# Check for live-boot scripts
|
||||||
|
file $TMPDIR/scripts/live # Should be "ASCII text"
|
||||||
|
# OR (some initramfs have main/ prefix)
|
||||||
|
file $TMPDIR/main/scripts/live
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common failures
|
||||||
|
1. **live-boot not in initrd**: Installed in rootfs but initramfs not regenerated after
|
||||||
|
2. **Missing kernel params**: `boot=live` not in GRUB/ISOLINUX config
|
||||||
|
3. **Broken initramfs**: Built without /proc /sys /dev mounted in chroot
|
||||||
|
4. **Wrong verification**: `[ -d scripts/live ]` fails because it's a file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. debootstrap for Installer Environments
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
- `--variant=minbase`: Absolute minimum (~150MB). Only essential + apt. Good for installer squashfs.
|
||||||
|
- Default (no variant): Full base system (~300MB). More packages, fewer missing deps.
|
||||||
|
|
||||||
|
### --include limitations
|
||||||
|
debootstrap's minbase resolver is simplified and **cannot resolve complex dependency chains**. Packages like `live-boot` that depend on `initramfs-tools` which depends on many other packages will silently fail or be skipped.
|
||||||
|
|
||||||
|
**Fix**: Install complex packages via `chroot apt-get` after debootstrap completes:
|
||||||
|
```bash
|
||||||
|
debootstrap --variant=minbase --include=basic,packages bookworm /installer http://deb.debian.org/debian
|
||||||
|
# Then:
|
||||||
|
mount --bind /proc /installer/proc
|
||||||
|
mount --bind /sys /installer/sys
|
||||||
|
mount --bind /dev /installer/dev
|
||||||
|
chroot /installer apt-get update
|
||||||
|
chroot /installer apt-get install -y live-boot live-boot-initramfs-tools
|
||||||
|
umount /installer/dev /installer/sys /installer/proc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initramfs generation inside containers
|
||||||
|
`update-initramfs` REQUIRES `/proc`, `/sys`, `/dev` to be mounted in the chroot. Without them:
|
||||||
|
- Module detection fails (can't read /proc/modules)
|
||||||
|
- Device nodes missing (can't detect hardware)
|
||||||
|
- The resulting initramfs boots but can't load kernel modules
|
||||||
|
|
||||||
|
### Container-in-container considerations
|
||||||
|
When running debootstrap inside a Podman/Docker container on a CI runner:
|
||||||
|
- `--privileged` flag needed for chroot to work
|
||||||
|
- The container runtime may kill the container after debootstrap exits if using `set -e`
|
||||||
|
- proc/sys/dev mounts inside the debootstrapped chroot work fine with `--privileged`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. GRUB Theming
|
||||||
|
|
||||||
|
### theme.txt format
|
||||||
|
```
|
||||||
|
desktop-color: "#0a0a0a" # Fallback background color
|
||||||
|
desktop-image: "background.png" # Background image (any PNG, GRUB scales)
|
||||||
|
title-text: "" # Empty = hide title
|
||||||
|
|
||||||
|
+ boot_menu {
|
||||||
|
left = 25%
|
||||||
|
top = 40%
|
||||||
|
width = 50%
|
||||||
|
height = 30%
|
||||||
|
item_color = "#aaaaaa" # Normal menu item color
|
||||||
|
selected_item_color = "#fb923c" # Selected item color
|
||||||
|
item_height = 36
|
||||||
|
item_spacing = 8
|
||||||
|
scrollbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
left = 25%
|
||||||
|
top = 20%
|
||||||
|
width = 50%
|
||||||
|
text = "Some Text"
|
||||||
|
color = "#f7931a"
|
||||||
|
align = "center"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT specify `font = "Name Size"` in theme elements unless you know the exact internal font name. If GRUB can't find the font, the ENTIRE theme fails to load and you get the ugly default.
|
||||||
|
|
||||||
|
### Font handling
|
||||||
|
```bash
|
||||||
|
# Generate .pf2 font file
|
||||||
|
grub-mkfont -s 16 -o dejavu_16.pf2 /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
|
||||||
|
|
||||||
|
# In grub.cfg, load fonts BEFORE setting theme:
|
||||||
|
loadfont /boot/grub/font.pf2
|
||||||
|
loadfont /boot/grub/themes/archipelago/dejavu_16.pf2
|
||||||
|
set theme=/boot/grub/themes/archipelago/theme.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background images
|
||||||
|
- Any PNG works, GRUB scales to screen resolution
|
||||||
|
- Smaller images (1024x768) load faster
|
||||||
|
- Large images (3000x2000+) add seconds to boot and may fail on limited GRUB heap
|
||||||
|
|
||||||
|
### grub-mkimage — essential modules for ISO boot
|
||||||
|
```bash
|
||||||
|
grub-mkimage -O x86_64-efi -o BOOTX64.EFI -p /boot/grub \
|
||||||
|
part_gpt part_msdos fat iso9660 udf \ # Filesystem access
|
||||||
|
normal boot linux search search_fs_uuid search_fs_file search_label \
|
||||||
|
configfile echo cat ls test true \ # Basic commands
|
||||||
|
loopback \ # Loop device support
|
||||||
|
gfxterm gfxmenu font png \ # Graphical display
|
||||||
|
all_video video video_bochs video_cirrus \ # Video drivers
|
||||||
|
efi_gop efi_uga # EFI display protocols
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing `all_video`/`efi_gop` = black screen on real hardware (works in QEMU).
|
||||||
|
|
||||||
|
### EFI boot image creation
|
||||||
|
```bash
|
||||||
|
dd if=/dev/zero of=efi.img bs=1M count=4
|
||||||
|
mkfs.vfat efi.img
|
||||||
|
mmd -i efi.img ::/EFI ::/EFI/BOOT
|
||||||
|
mcopy -i efi.img BOOTX64.EFI ::/EFI/BOOT/BOOTX64.EFI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Plymouth Boot Splash
|
||||||
|
|
||||||
|
### Theme types
|
||||||
|
- **script**: Most flexible. Lua-like scripting with sprites, animations, callbacks.
|
||||||
|
- **two-step**: Simple logo + spinner. Less customizable but easier.
|
||||||
|
- **fade-in**: Logo fades in. Minimal.
|
||||||
|
|
||||||
|
### Script theme structure
|
||||||
|
```
|
||||||
|
/usr/share/plymouth/themes/mytheme/
|
||||||
|
mytheme.plymouth # Theme metadata
|
||||||
|
mytheme.script # Animation script
|
||||||
|
logo.png # Logo image (PNG with alpha)
|
||||||
|
```
|
||||||
|
|
||||||
|
### mytheme.plymouth
|
||||||
|
```ini
|
||||||
|
[Plymouth Theme]
|
||||||
|
Name=MyTheme
|
||||||
|
Description=Custom boot splash
|
||||||
|
ModuleName=script
|
||||||
|
|
||||||
|
[script]
|
||||||
|
ImageDir=/usr/share/plymouth/themes/mytheme
|
||||||
|
ScriptFile=/usr/share/plymouth/themes/mytheme/mytheme.script
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script language key functions
|
||||||
|
```javascript
|
||||||
|
Window.SetBackgroundTopColor(r, g, b); // 0.0-1.0 floats
|
||||||
|
Window.SetBackgroundBottomColor(r, g, b);
|
||||||
|
image = Image("logo.png");
|
||||||
|
sprite = Sprite(image);
|
||||||
|
sprite.SetX(x); sprite.SetY(y); sprite.SetOpacity(0.0-1.0);
|
||||||
|
Plymouth.SetRefreshFunction(fn); // Called every frame
|
||||||
|
Plymouth.SetBootProgressFunction(fn); // fn(duration, progress)
|
||||||
|
Plymouth.SetDisplayPasswordFunction(fn); // fn(prompt, bullets)
|
||||||
|
Plymouth.SetQuitFunction(fn);
|
||||||
|
screen_w = Window.GetWidth();
|
||||||
|
screen_h = Window.GetHeight();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting default theme
|
||||||
|
```bash
|
||||||
|
plymouth-set-default-theme mytheme
|
||||||
|
# OR manually:
|
||||||
|
ln -sf /usr/share/plymouth/themes/mytheme/mytheme.plymouth /etc/alternatives/default.plymouth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kernel params
|
||||||
|
- `splash` in GRUB_CMDLINE_LINUX_DEFAULT enables Plymouth
|
||||||
|
- `quiet` suppresses text that would overlay Plymouth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ISOLINUX/SYSLINUX
|
||||||
|
|
||||||
|
### Required files
|
||||||
|
| File | Source | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `isolinux.bin` | `/usr/lib/ISOLINUX/isolinux.bin` | BIOS bootloader |
|
||||||
|
| `ldlinux.c32` | `/usr/lib/syslinux/modules/bios/ldlinux.c32` | Core library (REQUIRED) |
|
||||||
|
| `menu.c32` | `/usr/lib/syslinux/modules/bios/menu.c32` | Text menu UI |
|
||||||
|
| `libutil.c32` | `/usr/lib/syslinux/modules/bios/libutil.c32` | Utility library |
|
||||||
|
| `boot.cat` | Auto-generated by xorriso | El Torito boot catalog |
|
||||||
|
| `isohdpfx.bin` | Extracted from working ISO | Hybrid MBR code |
|
||||||
|
|
||||||
|
### Configuration (isolinux.cfg)
|
||||||
|
```
|
||||||
|
UI menu.c32
|
||||||
|
PROMPT 0
|
||||||
|
TIMEOUT 50 # 5 seconds (units of 1/10 second)
|
||||||
|
DEFAULT install
|
||||||
|
|
||||||
|
MENU TITLE MY INSTALLER
|
||||||
|
MENU COLOR border 30;44 #40ffffff #00000000 std
|
||||||
|
MENU COLOR title 1;36;44 #ff00b7ff #00000000 std
|
||||||
|
MENU COLOR sel 7;37;40 #ffffffff #ff333333 std
|
||||||
|
MENU COLOR unsel 37;44 #ffaaaaaa #00000000 std
|
||||||
|
|
||||||
|
LABEL install
|
||||||
|
MENU LABEL Install System
|
||||||
|
KERNEL /live/vmlinuz
|
||||||
|
APPEND initrd=/live/initrd.img boot=live components quiet
|
||||||
|
MENU DEFAULT
|
||||||
|
```
|
||||||
|
|
||||||
|
### menu.c32 vs vesamenu.c32
|
||||||
|
- `menu.c32`: Text-mode menu. More compatible, no background image.
|
||||||
|
- `vesamenu.c32`: VESA graphical menu. Supports background PNG, but some hardware/VMs don't support VESA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing Without Real Hardware
|
||||||
|
|
||||||
|
### QEMU UEFI boot
|
||||||
|
```bash
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-machine q35 \
|
||||||
|
-drive if=pflash,format=raw,readonly=on,file=/path/to/OVMF_CODE.fd \
|
||||||
|
-m 4G -smp 2 \
|
||||||
|
-boot d -cdrom image.iso \
|
||||||
|
-drive if=virtio,format=qcow2,file=test-disk.qcow2 \
|
||||||
|
-vga virtio -display default
|
||||||
|
```
|
||||||
|
|
||||||
|
### QEMU BIOS boot (sees ISOLINUX)
|
||||||
|
```bash
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-machine pc \
|
||||||
|
-m 4G -smp 2 \
|
||||||
|
-boot d -cdrom image.iso \
|
||||||
|
-drive if=virtio,format=qcow2,file=test-disk.qcow2 \
|
||||||
|
-vga virtio -display default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serial console capture
|
||||||
|
Add to QEMU: `-serial file:/tmp/serial.log`
|
||||||
|
Add to kernel params: `console=ttyS0,115200 console=tty0`
|
||||||
|
|
||||||
|
### ISO structure verification (no boot required)
|
||||||
|
```bash
|
||||||
|
MNT=$(mktemp -d)
|
||||||
|
sudo mount -o loop,ro image.iso $MNT
|
||||||
|
|
||||||
|
# Check all critical files
|
||||||
|
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||||
|
isolinux/isolinux.bin EFI/BOOT/BOOTX64.EFI boot/grub/grub.cfg; do
|
||||||
|
[ -e $MNT/$f ] && echo "OK: $f" || echo "MISSING: $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check initramfs for live-boot
|
||||||
|
INITRD=$(mktemp -d)
|
||||||
|
unmkinitramfs $MNT/live/initrd.img $INITRD
|
||||||
|
[ -e $INITRD/scripts/live ] && echo "live-boot: OK" || echo "live-boot: MISSING"
|
||||||
|
|
||||||
|
# Check kernel params
|
||||||
|
grep "boot=live" $MNT/boot/grub/grub.cfg && echo "params: OK"
|
||||||
|
|
||||||
|
sudo umount $MNT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Considerations for Custom ISOs
|
||||||
|
|
||||||
|
### Supply chain
|
||||||
|
- Pin the Debian mirror URL (don't use redirectors in production)
|
||||||
|
- Verify package signatures (debootstrap does this by default)
|
||||||
|
- Pin kernel and GRUB package versions for reproducibility
|
||||||
|
|
||||||
|
### Installer security
|
||||||
|
- Auto-install.sh runs as root — validate all inputs before path construction
|
||||||
|
- LUKS key generation must use CSPRNG (`/dev/urandom`, never `/dev/random` which blocks)
|
||||||
|
- Drop the LUKS key file after writing to crypttab (or store in root-only location with 0400)
|
||||||
|
|
||||||
|
### Boot security
|
||||||
|
- Secure Boot requires signed GRUB EFI binary (shim-signed package)
|
||||||
|
- Without Secure Boot, the unsigned BOOTX64.EFI works but users must disable Secure Boot in BIOS
|
||||||
|
- The MBR code (isohdpfx.bin) is not signed — Secure Boot only validates EFI path
|
||||||
@@ -4,6 +4,7 @@ description: >
|
|||||||
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
|
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
|
||||||
port mappings, network connectivity, health status, restart policies, and config consistency
|
port mappings, network connectivity, health status, restart policies, and config consistency
|
||||||
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
|
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
|
||||||
|
Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||||
Use when asked to "diagnose containers", "check podman", "why is app not working",
|
Use when asked to "diagnose containers", "check podman", "why is app not working",
|
||||||
"container health check", "port not reachable", "audit containers", "podman status",
|
"container health check", "port not reachable", "audit containers", "podman status",
|
||||||
or when any container/app is misbehaving.
|
or when any container/app is misbehaving.
|
||||||
@@ -12,46 +13,123 @@ allowed-tools: Bash Read Glob Grep
|
|||||||
|
|
||||||
# Podman Doctor — Container Infrastructure Diagnostics
|
# Podman Doctor — Container Infrastructure Diagnostics
|
||||||
|
|
||||||
Systematic diagnostic for Archipelago's Podman container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, and config drift across all layers.
|
Systematic diagnostic for Archipelago's **rootless Podman** container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, UID mapping issues, and config drift across all layers.
|
||||||
|
|
||||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||||
|
|
||||||
|
> **ROOTLESS PODMAN**: Archipelago runs Podman as the `archipelago` user (UID 1000), NOT root.
|
||||||
|
> Never use `sudo podman` — use plain `podman` after SSH'ing in as the `archipelago` user.
|
||||||
|
> Container UIDs are mapped via subuid: container UID N → host UID (100000 + N).
|
||||||
|
|
||||||
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
|
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Step 1: Gather Runtime State
|
### Step 1: Gather Runtime State
|
||||||
|
|
||||||
Run these on the server:
|
Run these on the server (as `archipelago` user — NO sudo):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All containers with status, ports, networks
|
# All containers with status, ports, networks
|
||||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
|
podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
|
||||||
|
|
||||||
# Check for port conflicts on known ports
|
# Check for port conflicts on known ports
|
||||||
sudo ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
|
ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Check Restart Policies
|
### Step 2: Rootless Podman Health Check
|
||||||
|
|
||||||
|
Rootless Podman has specific requirements that must be verified:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify running as archipelago user (NOT root)
|
||||||
|
whoami # Must be "archipelago"
|
||||||
|
id # Must show uid=1000(archipelago)
|
||||||
|
|
||||||
|
# Check XDG_RUNTIME_DIR is set (required for rootless podman socket)
|
||||||
|
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" # Must be /run/user/1000
|
||||||
|
|
||||||
|
# Verify subuid/subgid mapping exists
|
||||||
|
grep archipelago /etc/subuid # Must show: archipelago:100000:65536
|
||||||
|
grep archipelago /etc/subgid # Must show: archipelago:100000:65536
|
||||||
|
|
||||||
|
# Verify user lingering is enabled (keeps user services after logout)
|
||||||
|
ls /var/lib/systemd/linger/ | grep archipelago # Must exist
|
||||||
|
|
||||||
|
# Check podman storage is accessible
|
||||||
|
podman info --format "{{.Store.GraphRoot}}" # ~/.local/share/containers/storage
|
||||||
|
ls -la ~/.local/share/containers/storage/ 2>/dev/null || echo "ERROR: Storage not accessible"
|
||||||
|
|
||||||
|
# Check podman socket
|
||||||
|
ls -la /run/user/1000/podman/ 2>/dev/null || echo "WARNING: No podman socket directory"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Check Restart Policies
|
||||||
|
|
||||||
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
|
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||||
echo -n "$c: "
|
echo -n "$c: "
|
||||||
sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
|
podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
**Red flag**: `no` or empty = container won't survive reboot.
|
**Red flag**: `no` or empty = container won't survive reboot.
|
||||||
|
|
||||||
### Step 3: Verify Port Mapping Consistency
|
### Step 4: Volume Ownership Audit (Rootless UID Mapping)
|
||||||
|
|
||||||
|
Rootless Podman maps container UIDs via subuid. Volume directories must be owned by the MAPPED UID, not the container UID. Formula: `host_uid = 100000 + container_uid`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "=== Volume Ownership Check ==="
|
||||||
|
|
||||||
|
# Default containers (run as root inside = UID 0 → host UID 100000)
|
||||||
|
for dir in lnd fedimint homeassistant jellyfin vaultwarden photoprism ollama filebrowser electrumx btcpay immich; do
|
||||||
|
if [ -d "/var/lib/archipelago/$dir" ]; then
|
||||||
|
owner=$(stat -c '%u:%g' "/var/lib/archipelago/$dir" 2>/dev/null)
|
||||||
|
if [ "$owner" != "100000:100000" ]; then
|
||||||
|
echo "WRONG: /var/lib/archipelago/$dir owned by $owner (should be 100000:100000)"
|
||||||
|
else
|
||||||
|
echo " OK: $dir → $owner"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Bitcoin Knots (container UID 101 → host UID 100101)
|
||||||
|
if [ -d "/var/lib/archipelago/bitcoin" ]; then
|
||||||
|
owner=$(stat -c '%u:%g' "/var/lib/archipelago/bitcoin")
|
||||||
|
[ "$owner" != "100101:100101" ] && echo "WRONG: bitcoin owned by $owner (should be 100101:100101)" || echo " OK: bitcoin → $owner"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PostgreSQL (container UID 70 → host UID 100070)
|
||||||
|
for dir in /var/lib/archipelago/*-db /var/lib/archipelago/postgres-*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
owner=$(stat -c '%u:%g' "$dir")
|
||||||
|
[ "$owner" != "100070:100070" ] && echo "WRONG: $dir owned by $owner (should be 100070:100070)" || echo " OK: $(basename $dir) → $owner"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Grafana (container UID 472 → host UID 100472)
|
||||||
|
if [ -d "/var/lib/archipelago/grafana" ]; then
|
||||||
|
owner=$(stat -c '%u:%g' "/var/lib/archipelago/grafana")
|
||||||
|
[ "$owner" != "100472:100472" ] && echo "WRONG: grafana owned by $owner (should be 100472:100472)" || echo " OK: grafana → $owner"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MariaDB/MySQL (container UID 999 → host UID 100999)
|
||||||
|
if [ -d "/var/lib/archipelago/mysql-mempool" ]; then
|
||||||
|
owner=$(stat -c '%u:%g' "/var/lib/archipelago/mysql-mempool")
|
||||||
|
[ "$owner" != "100999:100999" ] && echo "WRONG: mysql-mempool owned by $owner (should be 100999:100999)" || echo " OK: mysql-mempool → $owner"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Verify Port Mapping Consistency
|
||||||
|
|
||||||
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
|
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
|
||||||
|
|
||||||
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
|
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
|
||||||
|
|
||||||
**Layer 2 — Podman Runtime**: `sudo podman ps --format "{{.Names}}: {{.Ports}}"`
|
**Layer 2 — Podman Runtime**: `podman ps --format "{{.Names}}: {{.Ports}}"`
|
||||||
|
|
||||||
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
|
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
|
||||||
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
|
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
|
||||||
@@ -66,77 +144,114 @@ Cross-reference these 4 layers — mismatches between ANY two cause "app not loa
|
|||||||
| Works on port but not /app/ path | Missing nginx location block |
|
| Works on port but not /app/ path | Missing nginx location block |
|
||||||
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
|
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
|
||||||
|
|
||||||
### Step 4: Network Connectivity Audit
|
### Step 6: Network Connectivity Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Networks and their containers
|
# Networks and their containers
|
||||||
sudo podman network ls
|
podman network ls
|
||||||
sudo podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
|
podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
|
||||||
|
|
||||||
|
# Check container subnet (rootless uses 10.89.x.x, NOT 10.88.x.x)
|
||||||
|
podman network inspect archy-net --format "{{range .Subnets}}{{.Subnet}}{{end}}" 2>/dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
**Must be on archy-net**: bitcoin-knots, lnd, electrs, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
|
**Must be on archy-net**: bitcoin-knots, lnd, electrs/electrumx, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
|
||||||
|
|
||||||
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
|
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
|
||||||
|
|
||||||
### Step 5: Health Check Status
|
### Step 7: UFW Forward Policy Check
|
||||||
|
|
||||||
|
Rootless Podman requires `DEFAULT_FORWARD_POLICY="ACCEPT"` in UFW, otherwise container ports are unreachable from LAN.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||||
|
# Must be "ACCEPT", NOT "DROP"
|
||||||
|
# If DROP: containers work locally but NOT from other machines on the network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Systemd Service Sandbox Check
|
||||||
|
|
||||||
|
The `archipelago.service` must have specific settings relaxed for rootless Podman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check critical settings
|
||||||
|
systemctl cat archipelago.service | grep -E "ProtectHome|PrivateTmp|RestrictNamespaces|ReadWritePaths|XDG_RUNTIME_DIR"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required settings for rootless Podman**:
|
||||||
|
- `ProtectHome=no` — podman stores images in `~/.local/share/containers/`
|
||||||
|
- `PrivateTmp=no` or disabled — podman runtime uses `/tmp/podman-run-1000/`
|
||||||
|
- `RestrictNamespaces=` must NOT be set — rootless podman needs user namespaces
|
||||||
|
- `ReadWritePaths=` must include `/var/lib/archipelago /run/user /tmp`
|
||||||
|
- `Environment=XDG_RUNTIME_DIR=/run/user/1000`
|
||||||
|
|
||||||
|
### Step 9: Health Check Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Containers with health checks — are they passing?
|
# Containers with health checks — are they passing?
|
||||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
for c in $(podman ps --format "{{.Names}}"); do
|
||||||
health=$(sudo podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
|
health=$(podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
|
||||||
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
|
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
|
||||||
echo "$c: $health"
|
echo "$c: $health"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Containers WITHOUT health checks (gap in monitoring)
|
# Containers WITHOUT health checks (gap in monitoring)
|
||||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
for c in $(podman ps --format "{{.Names}}"); do
|
||||||
hc=$(sudo podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
|
hc=$(podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
|
||||||
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
|
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
|
||||||
echo "NO HEALTHCHECK: $c"
|
echo "NO HEALTHCHECK: $c"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 6: Resource & Failure Analysis
|
### Step 10: Resource & Failure Analysis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Resource usage
|
# Resource usage
|
||||||
sudo podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
||||||
|
|
||||||
# Recent deaths (last 24h)
|
# Recent deaths (last 24h)
|
||||||
sudo podman events --filter event=died --since 24h 2>/dev/null | tail -20
|
podman events --filter event=died --since 24h 2>/dev/null | tail -20
|
||||||
|
|
||||||
# OOM kills
|
# OOM kills
|
||||||
sudo podman ps -a --format "{{.Names}}" | while read c; do
|
podman ps -a --format "{{.Names}}" | while read c; do
|
||||||
oom=$(sudo podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
|
oom=$(podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
|
||||||
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
|
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Non-zero exits
|
# Non-zero exits
|
||||||
sudo podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
|
podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 7: Systemd Integration
|
### Step 11: Systemd Integration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl is-active archipelago nginx
|
systemctl is-active archipelago nginx
|
||||||
systemctl list-units --type=service | grep -i podman
|
systemctl --user list-units --type=service 2>/dev/null | grep -i podman
|
||||||
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
|
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 8: Generate Report
|
### Step 12: Generate Report
|
||||||
|
|
||||||
Produce a structured report:
|
Produce a structured report:
|
||||||
|
|
||||||
```
|
```
|
||||||
## Container Diagnostic Report
|
## Container Diagnostic Report
|
||||||
|
|
||||||
|
### Rootless Podman Status
|
||||||
|
- User: archipelago (UID 1000)
|
||||||
|
- Subuid mapping: [OK/MISSING]
|
||||||
|
- XDG_RUNTIME_DIR: [OK/MISSING]
|
||||||
|
- User linger: [enabled/disabled]
|
||||||
|
- UFW forward policy: [ACCEPT/DROP]
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
- Total containers: X running, Y stopped, Z unhealthy
|
- Total containers: X running, Y stopped, Z unhealthy
|
||||||
- Port conflicts: [list or "none"]
|
- Port conflicts: [list or "none"]
|
||||||
- Missing restart policies: [list or "none"]
|
- Missing restart policies: [list or "none"]
|
||||||
- Network issues: [list or "none"]
|
- Network issues: [list or "none"]
|
||||||
|
- UID mapping issues: [list or "none"]
|
||||||
- Health check gaps: [list]
|
- Health check gaps: [list]
|
||||||
|
|
||||||
### Critical Issues (fix immediately)
|
### Critical Issues (fix immediately)
|
||||||
@@ -154,3 +269,7 @@ After diagnosis, suggest running `/podman-fix` for any issues found.
|
|||||||
## Port Reference
|
## Port Reference
|
||||||
|
|
||||||
See `references/port-map.md` for the canonical port assignment table across all 4 layers.
|
See `references/port-map.md` for the canonical port assignment table across all 4 layers.
|
||||||
|
|
||||||
|
## UID Mapping Reference
|
||||||
|
|
||||||
|
See `references/uid-mapping.md` for the complete rootless UID mapping table.
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
# Common Podman Failure Patterns
|
# Common Podman Failure Patterns
|
||||||
|
|
||||||
|
## Rootless Podman Specific Failures
|
||||||
|
|
||||||
|
| Error | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `ERRO[0000] cannot find UID/GID for user` | subuid/subgid not configured | Add `archipelago:100000:65536` to `/etc/subuid` and `/etc/subgid` |
|
||||||
|
| `Error: unshare: operation not permitted` | Systemd `RestrictNamespaces` blocks user namespaces | Remove `RestrictNamespaces=` from `archipelago.service` |
|
||||||
|
| `Error: could not get runtime: creating runtime` | XDG_RUNTIME_DIR not set or /run/user/1000 missing | Set `Environment=XDG_RUNTIME_DIR=/run/user/1000` in service, ensure `loginctl enable-linger archipelago` |
|
||||||
|
| `permission denied` on volume mount | Wrong UID ownership — must use mapped UIDs | `sudo chown -R 100000:100000 /var/lib/archipelago/APP` (see UID mapping table) |
|
||||||
|
| `ERRO[0000] rootless containers not supported` | Podman not configured for rootless | Run `podman system migrate`, check `/etc/subuid` |
|
||||||
|
| `Error: creating container storage: layer not known` | Corrupted rootless storage | `podman system reset` (destroys all containers — last resort) |
|
||||||
|
| `Error: stat /tmp/podman-run-1000/...: no such file` | PrivateTmp=yes in systemd isolates /tmp | Set `PrivateTmp=no` in `archipelago.service` |
|
||||||
|
| Container ports unreachable from LAN | UFW DEFAULT_FORWARD_POLICY="DROP" | Change to "ACCEPT" in `/etc/default/ufw`, then `sudo ufw reload` |
|
||||||
|
| `Error: error creating network namespace` | Systemd `SystemCallFilter` blocks clone/unshare | Remove `SystemCallFilter=` from `archipelago.service` |
|
||||||
|
| Containers lose network after service restart | podman runtime dir in /tmp cleaned | Ensure `PrivateTmp=no` so /tmp/podman-run-1000/ persists |
|
||||||
|
|
||||||
## Container Won't Start
|
## Container Won't Start
|
||||||
|
|
||||||
| Error | Cause | Fix |
|
| Error | Cause | Fix |
|
||||||
|-------|-------|-----|
|
|-------|-------|-----|
|
||||||
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
|
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
|
||||||
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
|
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
|
||||||
| `permission denied` | Missing capability or read-only root | Check `get_app_capabilities()`, add tmpfs |
|
| `permission denied` | Missing capability, wrong UID ownership, or read-only root | Check capabilities, check volume ownership with mapped UID, add tmpfs |
|
||||||
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
|
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
|
||||||
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
|
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
|
||||||
| `no such network` | Network missing | `podman network create archy-net` |
|
| `no such network` | Network missing | `podman network create archy-net` |
|
||||||
|
| `Error: netavark: ...subnet overlap` | Network CIDR conflict | `podman network rm archy-net && podman network create archy-net` |
|
||||||
|
|
||||||
## Container Starts But App Unreachable
|
## Container Starts But App Unreachable
|
||||||
|
|
||||||
@@ -20,6 +36,7 @@
|
|||||||
| Port mapped but refused | Container logs | App crashing internally — check logs |
|
| Port mapped but refused | Container logs | App crashing internally — check logs |
|
||||||
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
|
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
|
||||||
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
|
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
|
||||||
|
| Works locally but not from LAN | UFW forward policy | Set `DEFAULT_FORWARD_POLICY="ACCEPT"` in `/etc/default/ufw` |
|
||||||
|
|
||||||
## Container Keeps Dying
|
## Container Keeps Dying
|
||||||
|
|
||||||
@@ -29,6 +46,8 @@
|
|||||||
| Dies after minutes | OOM killed | Increase `--memory` limit |
|
| Dies after minutes | OOM killed | Increase `--memory` limit |
|
||||||
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
|
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
|
||||||
| Crash loop | Repeated crash | Fix root cause, don't just restart |
|
| Crash loop | Repeated crash | Fix root cause, don't just restart |
|
||||||
|
| Exit code 127 | Missing binary in container | Wrong image tag or corrupted image — re-pull |
|
||||||
|
| Exit code 137 | Killed by OOM or signal | Check `dmesg` for OOM kill, check `podman inspect` for OOMKilled |
|
||||||
|
|
||||||
## Network Issues
|
## Network Issues
|
||||||
|
|
||||||
@@ -37,6 +56,20 @@
|
|||||||
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
|
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
|
||||||
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
|
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
|
||||||
| Container-to-container timeout | Different networks | Put both on same network |
|
| Container-to-container timeout | Different networks | Put both on same network |
|
||||||
|
| Bitcoin RPC refused from container | rpcallowip wrong subnet | Use `rpcallowip=0.0.0.0/0` (safe: port mapped, not exposed) |
|
||||||
|
| Old containers can't find new network | Subnet changed (rootful→rootless) | Recreate containers on new archy-net (rootless uses 10.89.x.x) |
|
||||||
|
|
||||||
|
## Volume Permission Patterns (Rootless UID Mapping)
|
||||||
|
|
||||||
|
Formula: **host_uid = 100000 + container_uid**
|
||||||
|
|
||||||
|
| Container UID | Host UID | Apps | Data Directory |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 (root) | 100000 | lnd, fedimint, homeassistant, jellyfin, vaultwarden, photoprism, ollama, filebrowser, electrumx, btcpay, immich | `/var/lib/archipelago/{app}` |
|
||||||
|
| 70 | 100070 | postgres (btcpay-db, immich-db, penpot-postgres) | `/var/lib/archipelago/postgres-*` |
|
||||||
|
| 101 | 100101 | bitcoin-knots | `/var/lib/archipelago/bitcoin` |
|
||||||
|
| 472 | 100472 | grafana | `/var/lib/archipelago/grafana` |
|
||||||
|
| 999 | 100999 | MariaDB (mysql-mempool) | `/var/lib/archipelago/mysql-mempool` |
|
||||||
|
|
||||||
## Capability Reference
|
## Capability Reference
|
||||||
|
|
||||||
@@ -47,9 +80,23 @@
|
|||||||
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
|
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
|
||||||
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
|
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
|
||||||
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
|
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
|
||||||
|
| NET_ADMIN + NET_RAW | tailscale | Can't create TUN device or manage routes |
|
||||||
|
|
||||||
## Read-Only Safe Apps
|
## Read-Only Safe Apps
|
||||||
|
|
||||||
Only these 8 apps can run with `--read-only`: searxng, grafana, filebrowser, electrs, nostr-rs-relay, ollama, indeedhub
|
Only these apps can run with `--read-only` + tmpfs: searxng, grafana, filebrowser, electrumx, mempool-electrs, electrs, nostr-rs-relay, ollama, indeedhub
|
||||||
|
|
||||||
All others need writable root or will fail silently.
|
All others need writable root or will fail silently.
|
||||||
|
|
||||||
|
## Systemd Sandbox Requirements for Rootless Podman
|
||||||
|
|
||||||
|
These systemd service settings MUST be configured for rootless Podman to work:
|
||||||
|
|
||||||
|
| Setting | Required Value | Why |
|
||||||
|
|---------|---------------|-----|
|
||||||
|
| `ProtectHome=` | `no` | Podman stores images in `~/.local/share/containers/` |
|
||||||
|
| `PrivateTmp=` | `no` | Podman runtime lives in `/tmp/podman-run-1000/` |
|
||||||
|
| `RestrictNamespaces=` | NOT SET | Rootless podman creates user namespaces |
|
||||||
|
| `SystemCallFilter=` | NOT SET | Rootless podman needs clone/unshare syscalls |
|
||||||
|
| `ReadWritePaths=` | Include `/var/lib/archipelago /run/user /tmp /etc/containers /var/lib/containers /run/containers` | Volume data + podman runtime paths |
|
||||||
|
| `Environment=` | `XDG_RUNTIME_DIR=/run/user/1000` | Podman socket location |
|
||||||
|
|||||||
93
.claude/skills/podman-doctor/references/uid-mapping.md
Normal file
93
.claude/skills/podman-doctor/references/uid-mapping.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Rootless Podman UID Mapping Reference
|
||||||
|
|
||||||
|
## How Rootless UID Mapping Works
|
||||||
|
|
||||||
|
When Podman runs as the `archipelago` user (UID 1000), container processes don't run as their "apparent" UID on the host. Instead, Linux user namespaces remap UIDs.
|
||||||
|
|
||||||
|
**Mapping formula**: `host_uid = 100000 + container_uid`
|
||||||
|
|
||||||
|
This is configured in `/etc/subuid` and `/etc/subgid`:
|
||||||
|
```
|
||||||
|
archipelago:100000:65536
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Container UID 0 (root inside container) → Host UID 100000 (unprivileged on host)
|
||||||
|
- Container UID 70 (postgres) → Host UID 100070
|
||||||
|
- Container UID 101 (bitcoin) → Host UID 100101
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Volume directories (bind mounts) on the host must be owned by the **mapped** UID, not the container UID. If Bitcoin runs as UID 101 inside its container, the host directory must be owned by UID 100101.
|
||||||
|
|
||||||
|
If ownership is wrong, the container gets `permission denied` when trying to read/write its data.
|
||||||
|
|
||||||
|
## Complete UID Mapping Table
|
||||||
|
|
||||||
|
| Container UID | Host UID | Containers | Fix Command |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 (root) | 100000 | lnd, fedimint, fedimint-gateway, homeassistant, jellyfin, vaultwarden, photoprism, ollama, filebrowser, electrumx, btcpay-server, nbxplorer, immich, nostr-rs-relay, strfry, nextcloud, searxng, onlyoffice, tailscale, uptime-kuma | `sudo chown -R 100000:100000 /var/lib/archipelago/{app}` |
|
||||||
|
| 70 | 100070 | postgres (btcpay-db, immich-db, penpot-postgres) | `sudo chown -R 100070:100070 /var/lib/archipelago/postgres-*` |
|
||||||
|
| 101 | 100101 | bitcoin-knots, bitcoin-core | `sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin` |
|
||||||
|
| 472 | 100472 | grafana | `sudo chown -R 100472:100472 /var/lib/archipelago/grafana` |
|
||||||
|
| 999 | 100999 | MariaDB (mysql-mempool) | `sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool` |
|
||||||
|
|
||||||
|
## How to Find a Container's UID
|
||||||
|
|
||||||
|
If you encounter a new container with permission issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what user the container runs as
|
||||||
|
podman inspect CONTAINER_NAME --format "{{.Config.User}}"
|
||||||
|
|
||||||
|
# If empty, it runs as root (UID 0) → host UID 100000
|
||||||
|
|
||||||
|
# If it shows a username, find the UID inside the image
|
||||||
|
podman run --rm IMAGE_NAME id
|
||||||
|
|
||||||
|
# Then calculate: host_uid = 100000 + container_uid
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix Script
|
||||||
|
|
||||||
|
Run this after any fresh install, migration, or when containers have permission errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Fix all rootless podman volume ownership
|
||||||
|
|
||||||
|
# UID 0 → 100000 (most containers)
|
||||||
|
for dir in lnd fedimint fedimint-gateway homeassistant jellyfin vaultwarden photoprism \
|
||||||
|
ollama filebrowser electrumx btcpay nbxplorer immich nostr-rs-relay nextcloud \
|
||||||
|
searxng onlyoffice uptime-kuma; do
|
||||||
|
[ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100000:100000 "/var/lib/archipelago/$dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
# UID 101 → 100101 (Bitcoin)
|
||||||
|
[ -d "/var/lib/archipelago/bitcoin" ] && sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin
|
||||||
|
|
||||||
|
# UID 70 → 100070 (PostgreSQL)
|
||||||
|
for dir in /var/lib/archipelago/postgres-* /var/lib/archipelago/btcpay-db /var/lib/archipelago/immich-db; do
|
||||||
|
[ -d "$dir" ] && sudo chown -R 100070:100070 "$dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
# UID 999 → 100999 (MariaDB)
|
||||||
|
[ -d "/var/lib/archipelago/mysql-mempool" ] && sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool
|
||||||
|
|
||||||
|
# UID 472 → 100472 (Grafana)
|
||||||
|
[ -d "/var/lib/archipelago/grafana" ] && sudo chown -R 100472:100472 /var/lib/archipelago/grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rootful vs Rootless Comparison
|
||||||
|
|
||||||
|
| Aspect | Rootful (old) | Rootless (current) |
|
||||||
|
|--------|---------------|-------------------|
|
||||||
|
| Podman command | `sudo podman` | `podman` (as archipelago user) |
|
||||||
|
| Container storage | `/var/lib/containers/storage` | `~/.local/share/containers/storage` |
|
||||||
|
| Container subnet | `10.88.0.0/16` | `10.89.0.0/16` |
|
||||||
|
| Volume ownership | Container UID directly | Mapped UID (100000 + container_uid) |
|
||||||
|
| Requires root? | Yes | No (except fixing volume ownership) |
|
||||||
|
| XDG_RUNTIME_DIR | Not needed | Required: `/run/user/1000` |
|
||||||
|
| User lingering | Not needed | Required: `loginctl enable-linger` |
|
||||||
|
| Systemd restrictions | All can be enabled | Must disable: RestrictNamespaces, SystemCallFilter |
|
||||||
@@ -2,19 +2,24 @@
|
|||||||
name: podman-fix
|
name: podman-fix
|
||||||
description: >
|
description: >
|
||||||
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
|
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
|
||||||
fix network connectivity, add missing restart policies, and resolve config drift.
|
fix network connectivity, add missing restart policies, fix rootless UID mapping, and resolve
|
||||||
|
config drift. Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||||
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
|
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
|
||||||
"app won't start", "fix podman", "repair container", "container down", or after /podman-doctor
|
"app won't start", "fix podman", "repair container", "container down", "permission denied",
|
||||||
identifies issues to fix.
|
or after /podman-doctor identifies issues to fix.
|
||||||
allowed-tools: Bash Read Edit Write Glob Grep
|
allowed-tools: Bash Read Edit Write Glob Grep
|
||||||
---
|
---
|
||||||
|
|
||||||
# Podman Fix — Container Remediation
|
# Podman Fix — Container Remediation
|
||||||
|
|
||||||
Targeted fix workflow for Podman container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
|
Targeted fix workflow for **rootless Podman** container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
|
||||||
|
|
||||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||||
|
|
||||||
|
> **ROOTLESS PODMAN**: All `podman` commands run as the `archipelago` user — NO sudo.
|
||||||
|
> Only use `sudo` for: chown on volume directories, UFW changes, systemd service edits, nginx reload.
|
||||||
|
> Container UIDs are mapped via subuid: container UID N → host UID (100000 + N).
|
||||||
|
|
||||||
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
|
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
|
||||||
|
|
||||||
## Fix Procedures
|
## Fix Procedures
|
||||||
@@ -23,21 +28,22 @@ If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check why it stopped
|
# Check why it stopped
|
||||||
sudo podman logs --tail 50 CONTAINER_NAME
|
podman logs --tail 50 CONTAINER_NAME
|
||||||
sudo podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
|
podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
|
||||||
|
|
||||||
# If clean exit or crash — just restart
|
# If clean exit or crash — just restart
|
||||||
sudo podman start CONTAINER_NAME
|
podman start CONTAINER_NAME
|
||||||
|
|
||||||
# If corrupt state — remove and recreate
|
# If corrupt state — remove and recreate
|
||||||
sudo podman rm -f CONTAINER_NAME
|
podman rm -f CONTAINER_NAME
|
||||||
# Then recreate using the install flow (trigger from UI or re-run creation command)
|
# Then recreate using the install flow (trigger from UI or re-run creation command)
|
||||||
```
|
```
|
||||||
|
|
||||||
**If container keeps crashing**: check logs for the actual error. Common causes:
|
**If container keeps crashing**, check logs for the actual error. Common causes:
|
||||||
- Missing config file → check if volume mount has the config
|
- Missing config file → check if volume mount has the config
|
||||||
- Wrong permissions → `chown -R` the data directory
|
- Wrong permissions → fix UID mapping (see Fix 8 below)
|
||||||
- Dependency not ready → start dependency first, wait, then start this container
|
- Dependency not ready → start dependency first, wait, then start this container
|
||||||
|
- Exit code 127 → missing binary in container image, re-pull the image
|
||||||
|
|
||||||
### Fix 2: Missing Restart Policy
|
### Fix 2: Missing Restart Policy
|
||||||
|
|
||||||
@@ -45,14 +51,14 @@ The most common uptime killer. Fix for ALL containers at once:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fix a single container
|
# Fix a single container
|
||||||
sudo podman update --restart unless-stopped CONTAINER_NAME
|
podman update --restart unless-stopped CONTAINER_NAME
|
||||||
|
|
||||||
# Fix ALL containers that have no restart policy
|
# Fix ALL containers that have no restart policy
|
||||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||||
echo "Fixing restart policy for: $c"
|
echo "Fixing restart policy for: $c"
|
||||||
sudo podman update --restart unless-stopped "$c"
|
podman update --restart unless-stopped "$c"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
@@ -66,23 +72,24 @@ done
|
|||||||
#### Port conflict (address already in use)
|
#### Port conflict (address already in use)
|
||||||
```bash
|
```bash
|
||||||
# Find what's using the port
|
# Find what's using the port
|
||||||
sudo ss -tlnp | grep :PORT_NUMBER
|
ss -tlnp | grep :PORT_NUMBER
|
||||||
|
|
||||||
# If it's another container, either change one's port or stop the conflicting one
|
# If it's another container, either change one's port or stop the conflicting one
|
||||||
sudo podman stop CONFLICTING_CONTAINER
|
podman stop CONFLICTING_CONTAINER
|
||||||
|
|
||||||
# If it's a host process
|
# If it's a host process (e.g., system tor vs container tor)
|
||||||
sudo kill PID # or stop the service
|
sudo systemctl stop tor # Stop system service if container needs the port
|
||||||
|
sudo systemctl disable tor
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Port not mapped (container running but port unreachable)
|
#### Port not mapped (container running but port unreachable)
|
||||||
```bash
|
```bash
|
||||||
# Check current port mappings
|
# Check current port mappings
|
||||||
sudo podman port CONTAINER_NAME
|
podman port CONTAINER_NAME
|
||||||
|
|
||||||
# Can't add ports to running container — must recreate
|
# Can't add ports to running container — must recreate
|
||||||
sudo podman stop CONTAINER_NAME
|
podman stop CONTAINER_NAME
|
||||||
sudo podman rm CONTAINER_NAME
|
podman rm CONTAINER_NAME
|
||||||
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
|
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -124,35 +131,51 @@ Edit `neode-ui/src/stores/appLauncher.ts`:
|
|||||||
#### Container not on archy-net (can't resolve other containers)
|
#### Container not on archy-net (can't resolve other containers)
|
||||||
```bash
|
```bash
|
||||||
# Connect to archy-net without recreating
|
# Connect to archy-net without recreating
|
||||||
sudo podman network connect archy-net CONTAINER_NAME
|
podman network connect archy-net CONTAINER_NAME
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
sudo podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
|
podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### archy-net doesn't exist
|
#### archy-net doesn't exist
|
||||||
```bash
|
```bash
|
||||||
sudo podman network create archy-net
|
podman network create archy-net
|
||||||
# Then reconnect all containers that need it
|
# Then reconnect all containers that need it
|
||||||
```
|
```
|
||||||
|
|
||||||
#### DNS not working inside container
|
#### DNS not working inside container
|
||||||
```bash
|
```bash
|
||||||
# Test DNS from inside container
|
# Test DNS from inside container
|
||||||
sudo podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
|
podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
|
||||||
sudo podman exec CONTAINER_NAME ping -c1 bitcoin-knots
|
podman exec CONTAINER_NAME ping -c1 bitcoin-knots
|
||||||
|
|
||||||
|
# If DNS fails, check the container's resolv.conf
|
||||||
|
podman exec CONTAINER_NAME cat /etc/resolv.conf
|
||||||
|
|
||||||
# If DNS fails, recreate container with explicit DNS
|
# If DNS fails, recreate container with explicit DNS
|
||||||
# Add --dns 1.1.1.1 to the podman run command
|
# Add --dns 1.1.1.1 to the podman run command
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Container subnet changed (rootful → rootless migration)
|
||||||
|
```bash
|
||||||
|
# Old rootful subnet: 10.88.0.0/16
|
||||||
|
# New rootless subnet: 10.89.0.0/16
|
||||||
|
# Bitcoin RPC rpcallowip must be updated if using subnet-specific allowlist
|
||||||
|
|
||||||
|
# Check current archy-net subnet
|
||||||
|
podman network inspect archy-net --format "{{range .Subnets}}{{.Subnet}}{{end}}"
|
||||||
|
|
||||||
|
# If Bitcoin RPC refuses connections from containers:
|
||||||
|
# Update bitcoin.conf rpcallowip to 0.0.0.0/0 (safe: only accessible via port mapping)
|
||||||
|
```
|
||||||
|
|
||||||
### Fix 5: Health Check Issues
|
### Fix 5: Health Check Issues
|
||||||
|
|
||||||
#### Add missing health check to running container
|
#### Add missing health check to running container
|
||||||
Can't add to running container — must recreate with health check flags:
|
Can't add to running container — must recreate with health check flags:
|
||||||
```bash
|
```bash
|
||||||
# Example for a web app
|
# Example for a web app
|
||||||
sudo podman run ... \
|
podman run ... \
|
||||||
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
|
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
|
||||||
--health-interval 30s \
|
--health-interval 30s \
|
||||||
--health-timeout 5s \
|
--health-timeout 5s \
|
||||||
@@ -164,10 +187,10 @@ sudo podman run ... \
|
|||||||
#### Fix unhealthy container
|
#### Fix unhealthy container
|
||||||
```bash
|
```bash
|
||||||
# See what the health check is actually running
|
# See what the health check is actually running
|
||||||
sudo podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
|
podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
|
||||||
|
|
||||||
# Run the health check manually to see the error
|
# Run the health check manually to see the error
|
||||||
sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
||||||
|
|
||||||
# Common fixes:
|
# Common fixes:
|
||||||
# - curl not installed in container → use wget or nc instead
|
# - curl not installed in container → use wget or nc instead
|
||||||
@@ -179,13 +202,10 @@ sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check what capabilities container has
|
# Check what capabilities container has
|
||||||
sudo podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
|
podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
|
||||||
|
|
||||||
# If missing required caps, must recreate with correct --cap-add flags
|
# If missing required caps, must recreate with correct --cap-add flags
|
||||||
# Refer to the capability reference in /podman-doctor references
|
# Refer to the capability reference in /podman-doctor references
|
||||||
|
|
||||||
# Fix data directory permissions
|
|
||||||
sudo chown -R 1000:1000 /var/lib/archipelago/APP_NAME/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fix 7: Full Config Consistency Fix
|
### Fix 7: Full Config Consistency Fix
|
||||||
@@ -199,12 +219,108 @@ When port map is inconsistent across layers, fix ALL layers:
|
|||||||
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
|
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
|
||||||
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
|
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
|
||||||
|
|
||||||
|
### Fix 8: Rootless UID Mapping (Permission Denied on Volumes)
|
||||||
|
|
||||||
|
This is the #1 rootless-specific issue. Container UIDs are remapped by user namespaces.
|
||||||
|
|
||||||
|
**Formula**: `host_uid = 100000 + container_uid`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fix UID 0 containers (most apps — run as root inside, mapped to 100000 on host)
|
||||||
|
sudo chown -R 100000:100000 /var/lib/archipelago/APP_NAME
|
||||||
|
|
||||||
|
# Fix Bitcoin (container UID 101 → host UID 100101)
|
||||||
|
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin
|
||||||
|
|
||||||
|
# Fix PostgreSQL (container UID 70 → host UID 100070)
|
||||||
|
sudo chown -R 100070:100070 /var/lib/archipelago/postgres-APP_NAME
|
||||||
|
|
||||||
|
# Fix Grafana (container UID 472 → host UID 100472)
|
||||||
|
sudo chown -R 100472:100472 /var/lib/archipelago/grafana
|
||||||
|
|
||||||
|
# Fix MariaDB (container UID 999 → host UID 100999)
|
||||||
|
sudo chown -R 100999:100999 /var/lib/archipelago/mysql-mempool
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to find the right UID for a new container:**
|
||||||
|
```bash
|
||||||
|
# Check what user the container image runs as
|
||||||
|
podman inspect IMAGE_NAME --format "{{.Config.User}}"
|
||||||
|
# If empty = root (UID 0) → host UID 100000
|
||||||
|
# If number → host UID = 100000 + that number
|
||||||
|
# If username → run: podman run --rm IMAGE_NAME id
|
||||||
|
```
|
||||||
|
|
||||||
|
After fixing ownership, restart the container:
|
||||||
|
```bash
|
||||||
|
podman restart CONTAINER_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 9: UFW Forward Policy (LAN Access Broken)
|
||||||
|
|
||||||
|
If containers work locally but not from other machines on the network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current policy
|
||||||
|
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||||
|
|
||||||
|
# Fix: change DROP to ACCEPT
|
||||||
|
sudo sed -i 's/DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
|
||||||
|
sudo ufw reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 10: Systemd Sandbox Too Restrictive
|
||||||
|
|
||||||
|
If the Rust backend can't scan/manage containers after a systemd update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what's blocked
|
||||||
|
sudo journalctl -u archipelago --since "10 min ago" | grep -i "denied\|permission\|namespace\|syscall"
|
||||||
|
|
||||||
|
# The archipelago.service MUST have these for rootless podman:
|
||||||
|
# ProtectHome=no
|
||||||
|
# PrivateTmp=no (or disabled)
|
||||||
|
# RestrictNamespaces= (NOT SET — don't restrict)
|
||||||
|
# SystemCallFilter= (NOT SET — don't filter)
|
||||||
|
# ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp
|
||||||
|
# Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the service file:
|
||||||
|
```bash
|
||||||
|
sudo systemctl edit archipelago.service
|
||||||
|
# Add overrides, then:
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart archipelago
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 11: Stale Podman Processes
|
||||||
|
|
||||||
|
If `podman ps` hangs or is very slow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kill stuck podman processes (>10 of them = something is wrong)
|
||||||
|
stuck=$(pgrep -c -f "podman ps\|podman stats" 2>/dev/null || echo 0)
|
||||||
|
if [ "$stuck" -gt 10 ]; then
|
||||||
|
pkill -f "podman ps\|podman stats"
|
||||||
|
echo "Killed $stuck stuck podman processes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill orphaned conmon processes holding ports
|
||||||
|
for pid in $(pgrep conmon); do
|
||||||
|
container=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | grep -oP '(?<=--cid )\S+')
|
||||||
|
if [ -n "$container" ] && ! podman ps -a --format "{{.ID}}" | grep -q "${container:0:12}"; then
|
||||||
|
kill "$pid" 2>/dev/null && echo "Killed orphan conmon $pid"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
## After Fixing
|
## After Fixing
|
||||||
|
|
||||||
Always verify the fix:
|
Always verify the fix:
|
||||||
```bash
|
```bash
|
||||||
# Container running?
|
# Container running?
|
||||||
sudo podman ps --filter name=CONTAINER_NAME
|
podman ps --filter name=CONTAINER_NAME
|
||||||
|
|
||||||
# Port reachable?
|
# Port reachable?
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
||||||
@@ -213,7 +329,10 @@ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
|||||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
|
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
|
||||||
|
|
||||||
# Health check passing?
|
# Health check passing?
|
||||||
sudo podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
|
podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
|
||||||
|
|
||||||
|
# Volume permissions correct? (rootless check)
|
||||||
|
podman exec CONTAINER_NAME ls -la /data/ 2>/dev/null || echo "Check container data path"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `/podman-doctor` again to confirm all issues are resolved.
|
Run `/podman-doctor` again to confirm all issues are resolved.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ name: podman-uptime
|
|||||||
description: >
|
description: >
|
||||||
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
|
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
|
||||||
restart policies, creates health check monitors, and configures auto-recovery for all
|
restart policies, creates health check monitors, and configures auto-recovery for all
|
||||||
containers. Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
|
containers. Handles rootless Podman (user: archipelago, UID 1000, subuid 100000:65536).
|
||||||
|
Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
|
||||||
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
|
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
|
||||||
"survive reboot", or to harden container reliability.
|
"survive reboot", or to harden container reliability.
|
||||||
allowed-tools: Bash Read Edit Write Glob Grep
|
allowed-tools: Bash Read Edit Write Glob Grep
|
||||||
@@ -15,6 +16,31 @@ Ensures every Archipelago container survives reboots, recovers from crashes, and
|
|||||||
|
|
||||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||||
|
|
||||||
|
> **ROOTLESS PODMAN**: All `podman` commands run as the `archipelago` user — NO sudo.
|
||||||
|
> Only use `sudo` for: systemd unit files, chown on volumes, UFW changes.
|
||||||
|
> The archipelago user runs containers directly via user namespaces.
|
||||||
|
|
||||||
|
## Prerequisites for Rootless Uptime
|
||||||
|
|
||||||
|
Before setting up uptime infrastructure, verify rootless Podman basics are working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Must be the archipelago user
|
||||||
|
whoami # archipelago
|
||||||
|
|
||||||
|
# User lingering must be enabled (keeps user services running after logout)
|
||||||
|
ls /var/lib/systemd/linger/ | grep archipelago || sudo loginctl enable-linger archipelago
|
||||||
|
|
||||||
|
# XDG_RUNTIME_DIR must be set
|
||||||
|
echo $XDG_RUNTIME_DIR # /run/user/1000
|
||||||
|
|
||||||
|
# Subuid/subgid must be configured
|
||||||
|
grep archipelago /etc/subuid # archipelago:100000:65536
|
||||||
|
|
||||||
|
# UFW forward policy must be ACCEPT (for LAN access to containers)
|
||||||
|
grep DEFAULT_FORWARD_POLICY /etc/default/ufw # Must be "ACCEPT"
|
||||||
|
```
|
||||||
|
|
||||||
## Layer 1: Restart Policies (Survive Reboots)
|
## Layer 1: Restart Policies (Survive Reboots)
|
||||||
|
|
||||||
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
||||||
@@ -23,28 +49,31 @@ Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Audit
|
# Audit
|
||||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||||
echo "$c: $policy"
|
echo "$c: $policy"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Fix any with "no" or empty policy
|
# Fix any with "no" or empty policy
|
||||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||||
echo "Fixing: $c"
|
echo "Fixing: $c"
|
||||||
sudo podman update --restart unless-stopped "$c"
|
podman update --restart unless-stopped "$c"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ensure podman auto-starts containers on boot
|
### Ensure podman auto-starts containers on boot
|
||||||
|
|
||||||
```bash
|
For rootless Podman, containers with restart policies are auto-started by `podman-restart` as a **user** service:
|
||||||
# Enable podman-restart service (restarts containers with restart policy on boot)
|
|
||||||
sudo systemctl enable podman-restart.service 2>/dev/null || true
|
|
||||||
|
|
||||||
# If podman-restart doesn't exist, create it
|
```bash
|
||||||
|
# Enable the rootless podman-restart user service
|
||||||
|
systemctl --user enable podman-restart.service 2>/dev/null
|
||||||
|
|
||||||
|
# If the user service doesn't exist, create a system-level one
|
||||||
|
# (runs as archipelago user via User= directive)
|
||||||
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
|
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Podman Start All Containers With Restart Policy
|
Description=Podman Start All Containers With Restart Policy
|
||||||
@@ -53,8 +82,12 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
User=archipelago
|
||||||
|
Group=archipelago
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||||
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
|
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
TimeoutStartSec=300
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -73,27 +106,31 @@ Create a systemd timer that checks container health every 2 minutes and restarts
|
|||||||
```bash
|
```bash
|
||||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
|
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Archipelago Container Watchdog
|
# Archipelago Container Watchdog (Rootless Podman)
|
||||||
# Checks all containers and restarts any that are stopped or unhealthy
|
# Runs as archipelago user — NO sudo for podman commands
|
||||||
|
|
||||||
LOG_TAG="container-watchdog"
|
LOG_TAG="container-watchdog"
|
||||||
|
|
||||||
|
# Run podman as the archipelago user with correct XDG path
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/1000
|
||||||
|
PODMAN="/usr/bin/podman"
|
||||||
|
|
||||||
# Restart any stopped containers that should be running (have restart policy)
|
# Restart any stopped containers that should be running (have restart policy)
|
||||||
for c in $(sudo podman ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}"); do
|
for c in $($PODMAN ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}" 2>/dev/null); do
|
||||||
logger -t "$LOG_TAG" "Restarting stopped container: $c"
|
logger -t "$LOG_TAG" "Restarting stopped container: $c"
|
||||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
$PODMAN start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Restart unhealthy containers
|
# Restart unhealthy containers
|
||||||
for c in $(sudo podman ps --filter health=unhealthy --format "{{.Names}}"); do
|
for c in $($PODMAN ps --filter health=unhealthy --format "{{.Names}}" 2>/dev/null); do
|
||||||
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
|
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
|
||||||
sudo podman restart "$c" 2>&1 | logger -t "$LOG_TAG"
|
$PODMAN restart "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check for containers in "created" state (never started)
|
# Check for containers in "created" state (never started)
|
||||||
for c in $(sudo podman ps -a --filter status=created --format "{{.Names}}"); do
|
for c in $($PODMAN ps -a --filter status=created --format "{{.Names}}" 2>/dev/null); do
|
||||||
logger -t "$LOG_TAG" "Starting created container: $c"
|
logger -t "$LOG_TAG" "Starting created container: $c"
|
||||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
$PODMAN start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||||
done
|
done
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|
||||||
@@ -103,7 +140,7 @@ sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh
|
|||||||
### Create the systemd timer
|
### Create the systemd timer
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Service unit
|
# Service unit — runs as archipelago user for rootless podman
|
||||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
|
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Archipelago Container Watchdog
|
Description=Archipelago Container Watchdog
|
||||||
@@ -111,6 +148,9 @@ After=podman-restart.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
User=archipelago
|
||||||
|
Group=archipelago
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||||
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
|
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -150,17 +190,20 @@ Some containers depend on others. The watchdog handles restarts, but initial boo
|
|||||||
```bash
|
```bash
|
||||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
|
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Ordered container startup for Archipelago
|
# Ordered container startup for Archipelago (Rootless Podman)
|
||||||
|
# Runs as archipelago user — NO sudo for podman commands
|
||||||
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
|
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
|
||||||
|
|
||||||
LOG_TAG="ordered-start"
|
LOG_TAG="ordered-start"
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/1000
|
||||||
|
PODMAN="/usr/bin/podman"
|
||||||
|
|
||||||
wait_for_container() {
|
wait_for_container() {
|
||||||
local name=$1
|
local name=$1
|
||||||
local max_wait=${2:-60}
|
local max_wait=${2:-60}
|
||||||
local waited=0
|
local waited=0
|
||||||
while [ $waited -lt $max_wait ]; do
|
while [ $waited -lt $max_wait ]; do
|
||||||
status=$(sudo podman inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
|
status=$($PODMAN inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
|
||||||
if [ "$status" = "true" ]; then
|
if [ "$status" = "true" ]; then
|
||||||
logger -t "$LOG_TAG" "$name is running"
|
logger -t "$LOG_TAG" "$name is running"
|
||||||
return 0
|
return 0
|
||||||
@@ -174,38 +217,45 @@ wait_for_container() {
|
|||||||
|
|
||||||
# Tier 0: Infrastructure
|
# Tier 0: Infrastructure
|
||||||
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
|
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
|
||||||
sudo podman start tailscale 2>/dev/null
|
$PODMAN start tailscale 2>/dev/null
|
||||||
|
|
||||||
# Tier 1: Bitcoin (foundation)
|
# Tier 1: Databases (must start before services that depend on them)
|
||||||
logger -t "$LOG_TAG" "Starting Tier 1: Bitcoin"
|
logger -t "$LOG_TAG" "Starting Tier 1: Databases"
|
||||||
sudo podman start bitcoin-knots 2>/dev/null
|
$PODMAN start mempool-db 2>/dev/null
|
||||||
|
$PODMAN start btcpay-postgres 2>/dev/null
|
||||||
|
$PODMAN start immich_postgres 2>/dev/null
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Tier 2: Bitcoin (foundation for Lightning and explorers)
|
||||||
|
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin"
|
||||||
|
$PODMAN start bitcoin-knots 2>/dev/null
|
||||||
wait_for_container bitcoin-knots 120
|
wait_for_container bitcoin-knots 120
|
||||||
|
|
||||||
# Tier 2: Bitcoin-dependent services
|
# Tier 3: Bitcoin-dependent services
|
||||||
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin-dependent"
|
logger -t "$LOG_TAG" "Starting Tier 3: Bitcoin-dependent"
|
||||||
sudo podman start electrs 2>/dev/null
|
$PODMAN start electrumx 2>/dev/null
|
||||||
sudo podman start lnd 2>/dev/null
|
$PODMAN start lnd 2>/dev/null
|
||||||
wait_for_container electrs 90
|
wait_for_container electrumx 90
|
||||||
wait_for_container lnd 90
|
wait_for_container lnd 90
|
||||||
|
|
||||||
# Tier 3: Services depending on Tier 2
|
# Tier 4: Services depending on Tier 3
|
||||||
logger -t "$LOG_TAG" "Starting Tier 3: Second-order dependencies"
|
logger -t "$LOG_TAG" "Starting Tier 4: Second-order dependencies"
|
||||||
sudo podman start mempool-db 2>/dev/null
|
$PODMAN start mempool 2>/dev/null
|
||||||
sleep 5
|
$PODMAN start nbxplorer 2>/dev/null
|
||||||
sudo podman start mempool 2>/dev/null
|
|
||||||
sudo podman start nbxplorer 2>/dev/null
|
|
||||||
sleep 10
|
sleep 10
|
||||||
sudo podman start btcpay-server 2>/dev/null
|
$PODMAN start btcpay-server 2>/dev/null
|
||||||
sudo podman start btcpay-postgres 2>/dev/null
|
$PODMAN start fedimint 2>/dev/null
|
||||||
|
$PODMAN start fedimint-gateway 2>/dev/null
|
||||||
|
|
||||||
# Tier 4: Independent apps (start all remaining)
|
# Tier 5: Independent apps (start all remaining)
|
||||||
logger -t "$LOG_TAG" "Starting Tier 4: Independent apps"
|
logger -t "$LOG_TAG" "Starting Tier 5: Independent apps"
|
||||||
sudo podman start --all 2>/dev/null
|
$PODMAN start --all 2>/dev/null
|
||||||
|
|
||||||
# Tier 5: UI containers (need parent apps running first)
|
# Tier 6: UI containers (need parent apps running first)
|
||||||
logger -t "$LOG_TAG" "Starting Tier 5: UI containers"
|
logger -t "$LOG_TAG" "Starting Tier 6: UI containers"
|
||||||
sudo podman start bitcoin-ui 2>/dev/null
|
$PODMAN start bitcoin-ui 2>/dev/null
|
||||||
sudo podman start lnd-ui 2>/dev/null
|
$PODMAN start lnd-ui 2>/dev/null
|
||||||
|
$PODMAN start electrs-ui 2>/dev/null
|
||||||
|
|
||||||
logger -t "$LOG_TAG" "Startup sequence complete"
|
logger -t "$LOG_TAG" "Startup sequence complete"
|
||||||
SCRIPT
|
SCRIPT
|
||||||
@@ -216,18 +266,22 @@ sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh
|
|||||||
### Wire into boot sequence
|
### Wire into boot sequence
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Runs as archipelago user for rootless podman
|
||||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
|
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Archipelago Ordered Container Startup
|
Description=Archipelago Ordered Container Startup
|
||||||
After=network-online.target podman.service
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
Before=archipelago.service
|
Before=archipelago.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
User=archipelago
|
||||||
|
Group=archipelago
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||||
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
|
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
TimeoutStartSec=300
|
TimeoutStartSec=600
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -237,14 +291,45 @@ sudo systemctl daemon-reload
|
|||||||
sudo systemctl enable archipelago-containers.service
|
sudo systemctl enable archipelago-containers.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Rootless-Specific Uptime Considerations
|
||||||
|
|
||||||
|
### Volume ownership survives reboots
|
||||||
|
Volume ownership doesn't change on reboot, but if a container image is updated (re-pulled), the new container may run as a different UID. Always verify after image updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick ownership audit after image pull
|
||||||
|
podman inspect CONTAINER_NAME --format "{{.Config.User}}"
|
||||||
|
# Then verify: sudo stat -c '%u:%g' /var/lib/archipelago/APP_NAME
|
||||||
|
# Formula: host_uid = 100000 + container_uid
|
||||||
|
```
|
||||||
|
|
||||||
|
### XDG_RUNTIME_DIR on boot
|
||||||
|
Rootless Podman requires `/run/user/1000` to exist. This is created by `pam_systemd` when the user logs in, or by `loginctl enable-linger`. If it's missing after boot, containers won't start.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify it exists
|
||||||
|
ls -la /run/user/1000/ || echo "CRITICAL: /run/user/1000 missing — run: sudo loginctl enable-linger archipelago"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd sandbox must not block podman
|
||||||
|
If the archipelago.service sandbox blocks namespace/syscall operations, the Rust backend can't scan containers. See Fix 10 in /podman-fix.
|
||||||
|
|
||||||
## Verification Checklist
|
## Verification Checklist
|
||||||
|
|
||||||
After setting up all 3 layers, verify:
|
After setting up all 3 layers, verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
echo "=== Rootless Podman Prerequisites ==="
|
||||||
|
echo "User: $(whoami)"
|
||||||
|
echo "XDG_RUNTIME_DIR: $XDG_RUNTIME_DIR"
|
||||||
|
grep archipelago /etc/subuid | head -1
|
||||||
|
ls /var/lib/systemd/linger/ | grep archipelago && echo "Linger: enabled" || echo "Linger: DISABLED"
|
||||||
|
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "=== Layer 1: Restart Policies ==="
|
echo "=== Layer 1: Restart Policies ==="
|
||||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
for c in $(podman ps -a --format "{{.Names}}"); do
|
||||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
policy=$(podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||||
echo " $c: $policy"
|
echo " $c: $policy"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -261,11 +346,19 @@ sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchd
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Container Health Summary ==="
|
echo "=== Container Health Summary ==="
|
||||||
total=$(sudo podman ps -a --format "{{.Names}}" | wc -l)
|
total=$(podman ps -a --format "{{.Names}}" | wc -l)
|
||||||
running=$(sudo podman ps --format "{{.Names}}" | wc -l)
|
running=$(podman ps --format "{{.Names}}" | wc -l)
|
||||||
stopped=$((total - running))
|
stopped=$((total - running))
|
||||||
unhealthy=$(sudo podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
|
unhealthy=$(podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
|
||||||
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
|
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Volume Ownership Spot Check ==="
|
||||||
|
for dir in bitcoin lnd grafana; do
|
||||||
|
if [ -d "/var/lib/archipelago/$dir" ]; then
|
||||||
|
echo " $dir: $(stat -c '%u:%g' /var/lib/archipelago/$dir)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reboot Test
|
## Reboot Test
|
||||||
@@ -274,17 +367,20 @@ The ultimate uptime test — reboot the server and verify everything comes back:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Before reboot: record running containers
|
# Before reboot: record running containers
|
||||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
|
podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
|
||||||
|
|
||||||
# Reboot
|
# Reboot
|
||||||
sudo reboot
|
sudo reboot
|
||||||
|
|
||||||
# After reboot (wait ~3 minutes, then SSH back in):
|
# After reboot (wait ~3 minutes, then SSH back in):
|
||||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
|
podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
|
||||||
|
|
||||||
# Compare
|
# Compare
|
||||||
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
||||||
# Should show no differences
|
# Should show no differences
|
||||||
|
|
||||||
|
# Also verify XDG_RUNTIME_DIR survived reboot
|
||||||
|
ls /run/user/1000/ || echo "CRITICAL: lingering not working"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
@@ -292,18 +388,23 @@ diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
|||||||
Check uptime status anytime:
|
Check uptime status anytime:
|
||||||
```bash
|
```bash
|
||||||
# Quick status
|
# Quick status
|
||||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
|
podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
|
||||||
|
|
||||||
# Watchdog activity
|
# Watchdog activity
|
||||||
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
|
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
|
||||||
|
|
||||||
# Container events (starts, stops, deaths)
|
# Container events (starts, stops, deaths)
|
||||||
sudo podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
|
podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
|
||||||
|
|
||||||
|
# Check for permission denied errors (rootless UID mapping issue)
|
||||||
|
podman ps -a --filter status=exited --format "{{.Names}}" | while read c; do
|
||||||
|
podman logs --tail 5 "$c" 2>&1 | grep -i "permission denied" && echo " ^ UID mapping issue in: $c"
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
|
|
||||||
- Run `/podman-doctor` first to identify issues
|
- Run `/podman-doctor` first to identify issues (includes rootless health checks)
|
||||||
- Run `/podman-fix` for specific container repairs
|
- Run `/podman-fix` for specific container repairs (includes UID mapping fixes)
|
||||||
- Run `/podman-uptime` to set up permanent reliability infrastructure
|
- Run `/podman-uptime` to set up permanent reliability infrastructure
|
||||||
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot
|
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot
|
||||||
|
|||||||
204
.gitea/workflows/build-iso-dev.yml
Normal file
204
.gitea/workflows/build-iso-dev.yml
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
name: Build Archipelago ISO (dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev-iso]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-iso:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Install ISO build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq \
|
||||||
|
debootstrap squashfs-tools xorriso \
|
||||||
|
isolinux syslinux-common mtools \
|
||||||
|
grub-efi-amd64-bin grub-pc-bin grub-common
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: |
|
||||||
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
|
export GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
|
cargo build --release --manifest-path core/Cargo.toml
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: cd neode-ui && npm ci && npm run build
|
||||||
|
|
||||||
|
- name: Type check frontend
|
||||||
|
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: cd neode-ui && npx vitest run
|
||||||
|
|
||||||
|
- name: Run container orchestration unit tests
|
||||||
|
run: |
|
||||||
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
|
echo "=== Container crate tests ==="
|
||||||
|
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
|
||||||
|
echo ""
|
||||||
|
echo "=== Orchestration integration tests ==="
|
||||||
|
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
|
||||||
|
|
||||||
|
- name: Configure root podman for insecure registry
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p /etc/containers/registries.conf.d
|
||||||
|
echo '[[registry]]
|
||||||
|
location = "80.71.235.15:3000"
|
||||||
|
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||||
|
|
||||||
|
- name: Build unbundled ISO
|
||||||
|
run: |
|
||||||
|
cd image-recipe
|
||||||
|
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||||
|
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||||
|
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||||
|
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||||
|
./build-auto-installer-iso.sh
|
||||||
|
|
||||||
|
- name: Smoke test ISO
|
||||||
|
run: |
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||||
|
if [ -z "$ISO" ]; then
|
||||||
|
echo "FAIL: No ISO produced"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||||
|
|
||||||
|
# Mount and verify structure
|
||||||
|
MNT=$(mktemp -d)
|
||||||
|
sudo mount -o loop,ro "$ISO" "$MNT"
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||||
|
isolinux/isolinux.bin isolinux/isolinux.cfg \
|
||||||
|
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
|
||||||
|
archipelago/auto-install.sh archipelago/rootfs.tar; do
|
||||||
|
if [ -e "$MNT/$f" ]; then
|
||||||
|
echo " OK: $f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
|
||||||
|
else
|
||||||
|
echo " MISSING: $f"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify initrd has live-boot
|
||||||
|
INITRD_DIR=$(mktemp -d)
|
||||||
|
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
|
||||||
|
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
|
||||||
|
echo " OK: initrd has live-boot scripts"
|
||||||
|
else
|
||||||
|
echo " MISSING: live-boot scripts in initrd!"
|
||||||
|
echo " initrd scripts/: $(ls "$INITRD_DIR/scripts/" 2>/dev/null || ls "$INITRD_DIR/main/scripts/" 2>/dev/null)"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check GRUB config has boot=live
|
||||||
|
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg"; then
|
||||||
|
echo " OK: grub.cfg has boot=live"
|
||||||
|
else
|
||||||
|
echo " MISSING: boot=live in grub.cfg"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo umount "$MNT" 2>/dev/null
|
||||||
|
rmdir "$MNT" 2>/dev/null
|
||||||
|
sudo rm -r "$INITRD_DIR" 2>/dev/null
|
||||||
|
|
||||||
|
if [ "$FAIL" = "1" ]; then
|
||||||
|
echo "SMOKE TEST FAILED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SMOKE TEST PASSED"
|
||||||
|
|
||||||
|
- name: QEMU boot test
|
||||||
|
timeout-minutes: 5
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||||
|
if [ -n "$ISO" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
|
||||||
|
echo "Running headless QEMU boot test..."
|
||||||
|
bash image-recipe/test-iso-qemu.sh "$ISO" 120
|
||||||
|
else
|
||||||
|
echo "Skipping QEMU test (no ISO or QEMU not available)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Copy to Builds
|
||||||
|
run: |
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||||
|
if [ -n "$ISO" ]; then
|
||||||
|
DATE=$(date +%Y%m%d-%H%M)
|
||||||
|
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-dev-unbundled-${DATE}.iso"
|
||||||
|
sudo cp "$ISO" "$DEST"
|
||||||
|
sudo chown 1000:1000 "$DEST"
|
||||||
|
echo "ISO: archipelago-dev-unbundled-${DATE}.iso"
|
||||||
|
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||||
|
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build report
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
set +eo pipefail
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo "DEV ISO BUILD REPORT"
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
||||||
|
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
|
||||||
|
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Runner: $(hostname)"
|
||||||
|
echo ""
|
||||||
|
echo "── Artifacts ──"
|
||||||
|
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
||||||
|
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-dev-*.iso 2>/dev/null | tail -3
|
||||||
|
echo ""
|
||||||
|
echo "── Rootfs contents check ──"
|
||||||
|
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||||
|
if [ -n "$ROOTFS" ]; then
|
||||||
|
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||||
|
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
else
|
||||||
|
echo " rootfs.tar not found in workspace"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "── ISO contents check ──"
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
||||||
|
if [ -n "$ISO" ]; then
|
||||||
|
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||||
|
ISO_MOUNT=$(mktemp -d)
|
||||||
|
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
||||||
|
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||||
|
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||||
|
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " vmlinuz: $([ -f "$ISO_MOUNT/live/vmlinuz" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " initrd: $([ -f "$ISO_MOUNT/live/initrd.img" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " squashfs: $([ -f "$ISO_MOUNT/live/filesystem.squashfs" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/live/filesystem.squashfs" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||||
|
echo " grub theme: $([ -d "$ISO_MOUNT/boot/grub/themes/archipelago" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " Could not mount ISO for inspection"
|
||||||
|
fi
|
||||||
|
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
|
||||||
|
- name: Fix workspace permissions
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
||||||
|
sudo chmod -R u+rwX . 2>/dev/null || true
|
||||||
|
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
||||||
|
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
||||||
130
.gitea/workflows/build-iso.yml
Normal file
130
.gitea/workflows/build-iso.yml
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
name: Build Archipelago ISO
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-iso:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
clean: false
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: |
|
||||||
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
|
cargo build --release --manifest-path core/Cargo.toml
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
rm -rf web/dist/neode-ui
|
||||||
|
cd neode-ui && npm ci && npm run build
|
||||||
|
|
||||||
|
- name: Type check frontend
|
||||||
|
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: cd neode-ui && npx vitest run
|
||||||
|
|
||||||
|
- name: Cache Debian Live ISO
|
||||||
|
run: |
|
||||||
|
WORK_DIR="image-recipe/build/auto-installer"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
CACHED="/home/archipelago/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
|
||||||
|
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
|
||||||
|
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
|
||||||
|
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Configure root podman for insecure registry
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p /etc/containers/registries.conf.d
|
||||||
|
echo '[[registry]]
|
||||||
|
location = "80.71.235.15:3000"
|
||||||
|
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||||
|
|
||||||
|
- name: Build unbundled ISO
|
||||||
|
run: |
|
||||||
|
cd image-recipe
|
||||||
|
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
||||||
|
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
||||||
|
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
||||||
|
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
||||||
|
./build-auto-installer-iso.sh
|
||||||
|
|
||||||
|
- name: Copy to Builds
|
||||||
|
run: |
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
||||||
|
if [ -n "$ISO" ]; then
|
||||||
|
DATE=$(date +%Y%m%d-%H%M)
|
||||||
|
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
||||||
|
sudo cp "$ISO" "$DEST"
|
||||||
|
sudo chown 1000:1000 "$DEST"
|
||||||
|
echo "ISO: archipelago-unbundled-${DATE}.iso"
|
||||||
|
echo "Size: $(du -h "$DEST" | cut -f1)"
|
||||||
|
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build report
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
set +eo pipefail
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo "BUILD REPORT"
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
||||||
|
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
|
||||||
|
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Runner: $(hostname)"
|
||||||
|
echo ""
|
||||||
|
echo "── Artifacts ──"
|
||||||
|
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
||||||
|
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
|
||||||
|
echo ""
|
||||||
|
echo "── Rootfs contents check ──"
|
||||||
|
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||||
|
if [ -n "$ROOTFS" ]; then
|
||||||
|
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||||
|
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
else
|
||||||
|
echo " rootfs.tar not found in workspace"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "── ISO contents check ──"
|
||||||
|
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
||||||
|
if [ -n "$ISO" ]; then
|
||||||
|
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||||
|
ISO_MOUNT=$(mktemp -d)
|
||||||
|
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
||||||
|
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||||
|
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
||||||
|
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
||||||
|
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " Could not mount ISO for inspection"
|
||||||
|
fi
|
||||||
|
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
|
||||||
|
- name: Fix workspace permissions
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
||||||
|
sudo chmod -R u+rwX . 2>/dev/null || true
|
||||||
|
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
||||||
|
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
||||||
60
.gitea/workflows/container-tests.yml
Normal file
60
.gitea/workflows/container-tests.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Container Orchestration Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev-iso, main]
|
||||||
|
paths:
|
||||||
|
- 'core/archipelago/src/**'
|
||||||
|
- 'core/container/src/**'
|
||||||
|
- 'scripts/container-*.sh'
|
||||||
|
- 'scripts/reconcile-*.sh'
|
||||||
|
- 'scripts/image-versions.sh'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
core/target
|
||||||
|
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Run orchestration unit tests
|
||||||
|
working-directory: core
|
||||||
|
run: |
|
||||||
|
echo "=== Container crate tests ==="
|
||||||
|
cargo test -p archipelago-container --no-fail-fast 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Orchestration integration tests ==="
|
||||||
|
cargo test --test orchestration_tests --no-fail-fast 2>&1
|
||||||
|
|
||||||
|
- name: Verify cargo check (full crate)
|
||||||
|
working-directory: core
|
||||||
|
run: cargo check --release 2>&1
|
||||||
|
|
||||||
|
smoke-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: unit-tests
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run container smoke tests on .228
|
||||||
|
env:
|
||||||
|
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
|
||||||
|
run: |
|
||||||
|
# Only run if SSH key exists (CI runner has deploy access)
|
||||||
|
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
|
||||||
|
bash scripts/dev-container-test.sh --once
|
||||||
|
else
|
||||||
|
echo "⚠ SSH key not available — skipping live smoke tests"
|
||||||
|
echo " To enable: add archipelago-deploy key to CI runner"
|
||||||
|
fi
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: Nightly Security Review
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '47 1 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security-review:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Claude Code
|
|
||||||
run: npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
- name: Run security review on recent changes
|
|
||||||
env:
|
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
run: |
|
|
||||||
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
echo "No recent changes to review"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
claude --print "Run a security review focused on these recently changed files:
|
|
||||||
$CHANGED
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- Constant-time comparison violations in crypto code
|
|
||||||
- Private key material in logs or error messages
|
|
||||||
- Floating-point Bitcoin amounts (must be integer sats)
|
|
||||||
- eval() or unsafe blocks without SAFETY comments
|
|
||||||
- Hardcoded credentials or secrets
|
|
||||||
- Missing input validation at API boundaries
|
|
||||||
|
|
||||||
Output a structured report with severity levels.
|
|
||||||
If any CRITICAL issues found, exit with code 1." > security-report.txt 2>&1
|
|
||||||
|
|
||||||
cat security-report.txt
|
|
||||||
|
|
||||||
if grep -qi "critical" security-report.txt; then
|
|
||||||
echo "::error::Critical security issues found — review security-report.txt"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
72
.gitea/workflows/post-install-tests.yml
Normal file
72
.gitea/workflows/post-install-tests.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Post-Install Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target:
|
||||||
|
description: 'Target node IP (e.g. 192.168.1.198)'
|
||||||
|
required: true
|
||||||
|
default: '192.168.1.198'
|
||||||
|
password:
|
||||||
|
description: 'Node password (or "auto" for fresh install)'
|
||||||
|
required: false
|
||||||
|
default: 'auto'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
post-install-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run post-install tests on target
|
||||||
|
run: |
|
||||||
|
TARGET="${{ github.event.inputs.target }}"
|
||||||
|
PASSWORD="${{ github.event.inputs.password }}"
|
||||||
|
if [ "$PASSWORD" = "auto" ]; then
|
||||||
|
PASSWORD="testpass123!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo "Running post-install tests on $TARGET"
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
|
||||||
|
# Copy test script to target and run
|
||||||
|
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
|
||||||
|
scripts/run-post-install-tests.sh \
|
||||||
|
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
|
||||||
|
scp -o StrictHostKeyChecking=no \
|
||||||
|
scripts/run-post-install-tests.sh \
|
||||||
|
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
|
||||||
|
|
||||||
|
# Run tests (with sudo for service checks)
|
||||||
|
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
|
||||||
|
archipelago@${TARGET} \
|
||||||
|
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
|
||||||
|
ssh -o StrictHostKeyChecking=no \
|
||||||
|
archipelago@${TARGET} \
|
||||||
|
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd neode-ui && npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: cd neode-ui && npx vue-tsc -b --noEmit
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd neode-ui && npx vitest run
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: cd neode-ui && npm audit --omit=dev
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Weekly Dependency Audit
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '13 2 * * 0'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Rust dependency audit
|
|
||||||
run: |
|
|
||||||
cargo install cargo-audit 2>/dev/null || true
|
|
||||||
echo "=== Cargo Audit ==="
|
|
||||||
cargo audit 2>&1 | tee cargo-audit.txt || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Version Pinning Check ==="
|
|
||||||
grep -n '"\*"' Cargo.toml || echo "No wildcard versions found"
|
|
||||||
|
|
||||||
- name: Check for critical vulnerabilities
|
|
||||||
run: |
|
|
||||||
if grep -qi "RUSTSEC.*critical\|vulnerability found" cargo-audit.txt 2>/dev/null; then
|
|
||||||
echo "::error::Critical Rust dependency vulnerabilities found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "No critical vulnerabilities detected"
|
|
||||||
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUST_VERSION: stable
|
||||||
|
NODE_VERSION: 18
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: Rust (fmt + clippy + test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: core
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --all-features
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (type-check + lint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: neode-ui
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: neode-ui/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -72,3 +72,4 @@ loop/loop.log.bak
|
|||||||
|
|
||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
|
|||||||
34
BACKLOG.md
34
BACKLOG.md
@@ -1,34 +0,0 @@
|
|||||||
# Archipelago Backlog
|
|
||||||
|
|
||||||
## Node Discovery & Spatial Map (Alpha Demo Feature)
|
|
||||||
|
|
||||||
**Priority:** High (needed for live alpha demo)
|
|
||||||
|
|
||||||
### "Find Nodes" — Spatial Node Discovery
|
|
||||||
|
|
||||||
Add a "Find Nodes" button to the Messages tab that opens a modal with an interactive spatial node map.
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Visual spatial map showing discovered Archipelago nodes
|
|
||||||
- Each node displays its self-chosen name (pseudonym)
|
|
||||||
- Connection request flow: discover → request → peer approves → connected
|
|
||||||
- Optional locality broadcasting (toggle: share general area or stay anonymous)
|
|
||||||
- Cool, visual, presentation-worthy UI for live alpha demo
|
|
||||||
|
|
||||||
**Onboarding Addition:**
|
|
||||||
- Add "Name your node" step during setup/onboarding
|
|
||||||
- Include privacy guidance: "Use a pseudonym if you want privacy"
|
|
||||||
- Node name is broadcast on the discovery network
|
|
||||||
|
|
||||||
**Technical Notes:**
|
|
||||||
- Builds on existing Nostr-based node discovery (`node-nostr-discover` RPC)
|
|
||||||
- Existing peer system: `node-add-peer`, `node-remove-peer`, `node-list-peers`
|
|
||||||
- Need to add: connection request/approval flow (currently peers are added directly)
|
|
||||||
- Spatial visualization could use force-directed graph or map-based layout
|
|
||||||
- Locality data is optional and coarse-grained (city/region level, never precise)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings (TBD)
|
|
||||||
|
|
||||||
*User mentioned settings changes needed — details to be clarified.*
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# Archipelago Build System - Summary
|
|
||||||
|
|
||||||
## ✅ What We Created Today
|
|
||||||
|
|
||||||
### 1. **Complete One-Script Build System** (`build-iso-complete.sh`)
|
|
||||||
- Handles backend compilation (Rust)
|
|
||||||
- Handles frontend build (Vue.js)
|
|
||||||
- Creates bootable ISO image
|
|
||||||
- Supports local and remote builds
|
|
||||||
- Smart artifact caching
|
|
||||||
- Full error checking and validation
|
|
||||||
|
|
||||||
### 2. **Comprehensive Documentation** (`BUILD-GUIDE.md`)
|
|
||||||
- Quick start guide
|
|
||||||
- Detailed build options
|
|
||||||
- Troubleshooting section
|
|
||||||
- Development workflow
|
|
||||||
- CI/CD integration examples
|
|
||||||
|
|
||||||
### 3. **Fixed ISO Auto-Start Issue**
|
|
||||||
- Identified root cause: `read -p` prompt blocking auto-launch
|
|
||||||
- Restored working auto-start logic from previous builds
|
|
||||||
- Menu now launches automatically after 1 second
|
|
||||||
|
|
||||||
## 🚀 How to Use
|
|
||||||
|
|
||||||
### Quick Build
|
|
||||||
```bash
|
|
||||||
# One command - builds everything and creates flashable ISO
|
|
||||||
./build-iso-complete.sh --remote archipelago@192.168.1.228
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flash to USB
|
|
||||||
```bash
|
|
||||||
# After build completes
|
|
||||||
./flash-to-usb.sh /dev/diskN
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 What the Build Process Does
|
|
||||||
|
|
||||||
```
|
|
||||||
Source Code
|
|
||||||
│
|
|
||||||
├─→ Backend (Rust) ────→ Binary (10MB)
|
|
||||||
│ ↓
|
|
||||||
├─→ Frontend (Vue) ────→ Assets (5MB)
|
|
||||||
│ ↓
|
|
||||||
└─→ ISO Builder ────────→ Bootable ISO (1.2GB)
|
|
||||||
↓
|
|
||||||
Flash to USB
|
|
||||||
↓
|
|
||||||
Boot & Install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Steps
|
|
||||||
|
|
||||||
1. **Backend Compilation** (Rust → Native Binary)
|
|
||||||
- `core/archipelago/` → `image-recipe/build/backend/archipelago`
|
|
||||||
- Can build locally or on remote server
|
|
||||||
- Incremental builds supported
|
|
||||||
|
|
||||||
2. **Frontend Build** (Vue.js → Static Assets)
|
|
||||||
- `neode-ui/` → `image-recipe/build/frontend/`
|
|
||||||
- Includes PWA manifest
|
|
||||||
- Optimized production build
|
|
||||||
|
|
||||||
3. **ISO Creation** (Debian Live)
|
|
||||||
- Downloads base Debian 12 ISO (~352MB)
|
|
||||||
- Integrates backend + frontend
|
|
||||||
- Configures auto-start services
|
|
||||||
- Creates bootable image
|
|
||||||
|
|
||||||
4. **Verification**
|
|
||||||
- Validates all artifacts
|
|
||||||
- Generates MD5 checksum
|
|
||||||
- Reports sizes
|
|
||||||
|
|
||||||
## 🎯 Key Features
|
|
||||||
|
|
||||||
### ✅ Smart Caching
|
|
||||||
- Skip backend build: `--skip-backend`
|
|
||||||
- Skip frontend build: `--skip-frontend`
|
|
||||||
- Debian ISO cached after first download
|
|
||||||
|
|
||||||
### ✅ Remote Build Support
|
|
||||||
- Build on development server (recommended)
|
|
||||||
- Automatically syncs code
|
|
||||||
- Copies artifacts back
|
|
||||||
|
|
||||||
### ✅ Clean Build Option
|
|
||||||
- `--clean` flag removes all artifacts
|
|
||||||
- Ensures fresh compilation
|
|
||||||
|
|
||||||
### ✅ Convenience Scripts
|
|
||||||
- `build-iso-complete.sh` - Main build script
|
|
||||||
- `flash-to-usb.sh` - Quick USB flashing
|
|
||||||
- Auto-generated after each build
|
|
||||||
|
|
||||||
## 📊 Build Time
|
|
||||||
|
|
||||||
| Build Type | Time |
|
|
||||||
|-----------|------|
|
|
||||||
| **First build** (clean) | 15-20 min |
|
|
||||||
| **Incremental** (code changes) | 3-5 min |
|
|
||||||
| **ISO only** (skip backend/frontend) | 2-3 min |
|
|
||||||
|
|
||||||
Breakdown:
|
|
||||||
- Debian ISO download: 5-10 min (first time only)
|
|
||||||
- Backend compile: 3-5 min (first time), ~30sec (incremental)
|
|
||||||
- Frontend build: 1-2 min
|
|
||||||
- ISO creation: 2-3 min
|
|
||||||
|
|
||||||
## 🔧 Development Workflow
|
|
||||||
|
|
||||||
### Making Backend Changes
|
|
||||||
```bash
|
|
||||||
# Edit Rust code in core/archipelago/src/
|
|
||||||
# Then rebuild:
|
|
||||||
./build-iso-complete.sh --remote HOST --skip-frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Making Frontend Changes
|
|
||||||
```bash
|
|
||||||
# Edit Vue.js code in neode-ui/src/
|
|
||||||
# Then rebuild:
|
|
||||||
./build-iso-complete.sh --remote HOST --skip-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Making Both Changes
|
|
||||||
```bash
|
|
||||||
./build-iso-complete.sh --remote HOST
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Current Build Status
|
|
||||||
|
|
||||||
### ✅ Completed
|
|
||||||
- Build system scripts created
|
|
||||||
- Documentation written
|
|
||||||
- Auto-start issue fixed
|
|
||||||
- README updated
|
|
||||||
|
|
||||||
### 🔄 In Progress
|
|
||||||
- ISO build running on `archipelago@192.168.1.228`
|
|
||||||
- Status: Downloading Debian ISO (34% complete)
|
|
||||||
- ETA: ~10 more minutes
|
|
||||||
|
|
||||||
### ⏳ Next
|
|
||||||
- Test new ISO on Dell OptiPlex
|
|
||||||
- Verify auto-start works
|
|
||||||
- Confirm Web UI accessible
|
|
||||||
|
|
||||||
## 🎯 What This Solves
|
|
||||||
|
|
||||||
### Before
|
|
||||||
- Manual backend compilation
|
|
||||||
- Manual frontend build
|
|
||||||
- Manual file copying
|
|
||||||
- Complex multi-step process
|
|
||||||
- Easy to miss steps
|
|
||||||
- Inconsistent builds
|
|
||||||
|
|
||||||
### After
|
|
||||||
- ✅ One command builds everything
|
|
||||||
- ✅ Automatic artifact management
|
|
||||||
- ✅ Smart caching for speed
|
|
||||||
- ✅ Consistent, reproducible builds
|
|
||||||
- ✅ Clear error messages
|
|
||||||
- ✅ Build verification
|
|
||||||
|
|
||||||
## 📂 File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
archy/
|
|
||||||
├── build-iso-complete.sh # Main build script (NEW)
|
|
||||||
├── flash-to-usb.sh # USB flash helper (auto-generated)
|
|
||||||
├── BUILD-GUIDE.md # Build documentation (NEW)
|
|
||||||
├── README.md # Updated with build info
|
|
||||||
├── core/archipelago/ # Rust backend
|
|
||||||
├── neode-ui/ # Vue.js frontend
|
|
||||||
└── image-recipe/
|
|
||||||
├── build/ # Build artifacts
|
|
||||||
│ ├── backend/ # Compiled binary
|
|
||||||
│ └── frontend/ # Built assets
|
|
||||||
├── results/ # Final ISO output
|
|
||||||
│ └── archipelago-debian-12-x86_64.iso
|
|
||||||
└── build-debian-iso.sh # ISO creation script
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 Security
|
|
||||||
|
|
||||||
Build system is designed to be secure:
|
|
||||||
- No hardcoded credentials
|
|
||||||
- SSH key authentication recommended
|
|
||||||
- `sudo` only when required (ISO creation)
|
|
||||||
- Build artifacts isolated in `build/` directory
|
|
||||||
- Clean separation of build/source directories
|
|
||||||
|
|
||||||
## 🌟 Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements:
|
|
||||||
- [ ] GitHub Actions CI/CD workflow
|
|
||||||
- [ ] Automatic version numbering
|
|
||||||
- [ ] Build signing for verification
|
|
||||||
- [ ] Multi-architecture support (ARM64)
|
|
||||||
- [ ] Docker-based builds
|
|
||||||
- [ ] Build caching improvements
|
|
||||||
- [ ] Parallel compilation
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
- **BUILD-GUIDE.md** - Comprehensive build guide
|
|
||||||
- **README.md** - Project overview with build quick start
|
|
||||||
- **build-iso-complete.sh** - Inline help with `--help` flag
|
|
||||||
|
|
||||||
## 🎉 Result
|
|
||||||
|
|
||||||
You now have a **production-grade build system** that:
|
|
||||||
- ✅ Builds from source with one command
|
|
||||||
- ✅ Handles all dependencies automatically
|
|
||||||
- ✅ Validates output
|
|
||||||
- ✅ Creates flashable ISO
|
|
||||||
- ✅ Supports iterative development
|
|
||||||
- ✅ Well-documented
|
|
||||||
- ✅ Easy to extend
|
|
||||||
|
|
||||||
**Next step:** Once the current ISO build completes, test it on the Dell OptiPlex to verify auto-start works!
|
|
||||||
193
BUILD-UPDATES.md
193
BUILD-UPDATES.md
@@ -1,193 +0,0 @@
|
|||||||
# Build System Updates - Feb 1, 2026
|
|
||||||
|
|
||||||
## ✅ Completed
|
|
||||||
|
|
||||||
### 1. **Frontend Deployment**
|
|
||||||
- ✅ Updated Ollama icon to new `ollama.webp`
|
|
||||||
- ✅ Built and deployed to live dev server (`192.168.1.228`)
|
|
||||||
- ✅ Web UI now live at `http://192.168.1.228`
|
|
||||||
|
|
||||||
### 2. **Enhanced ISO Build Script**
|
|
||||||
|
|
||||||
#### Progress Indicators
|
|
||||||
```bash
|
|
||||||
# Before:
|
|
||||||
📥 Downloading Debian Live 12 (Bookworm) Standard ISO...
|
|
||||||
|
|
||||||
# After:
|
|
||||||
📥 Downloading Debian Live 12 (Bookworm) Standard ISO...
|
|
||||||
Size: ~352MB | This is a one-time download (cached for future builds)
|
|
||||||
|
|
||||||
[████████████████████████████████████] 100%
|
|
||||||
|
|
||||||
✅ Downloaded Debian Live ISO (352M)
|
|
||||||
📝 Cached at: /path/to/iso
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Build Timer
|
|
||||||
- Tracks total build time
|
|
||||||
- Shows start time
|
|
||||||
- Reports duration in minutes/seconds
|
|
||||||
|
|
||||||
#### Better Caching
|
|
||||||
- Detects cached ISO with size validation
|
|
||||||
- Shows cache location and size
|
|
||||||
- Handles both macOS and Linux stat commands
|
|
||||||
|
|
||||||
#### Enhanced Build Summary
|
|
||||||
```bash
|
|
||||||
╔════════════════════════════════════════════════════════╗
|
|
||||||
║ 🎉 Build Complete! ║
|
|
||||||
╚════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
📀 ISO File: /path/to/archipelago-debian-12-x86_64.iso
|
|
||||||
📏 Size: 1.2G
|
|
||||||
🔐 MD5: a3f2d8c9e4b1...
|
|
||||||
⏱️ Build Time: 15m 32s
|
|
||||||
🎯 Base: Debian 12 Live (Bookworm)
|
|
||||||
|
|
||||||
🔥 Next Steps:
|
|
||||||
|
|
||||||
1. Flash to USB:
|
|
||||||
cd image-recipe && ./write-usb-dd.sh /dev/diskN
|
|
||||||
|
|
||||||
2. Boot on target device
|
|
||||||
|
|
||||||
3. Auto-login as 'user' with menu launch
|
|
||||||
|
|
||||||
4. Access Web UI at http://<IP>:5678
|
|
||||||
|
|
||||||
5. SSH access: ssh user@<IP> (password: archipelago)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **One-Script Build System**
|
|
||||||
Created `build-iso-complete.sh` with:
|
|
||||||
- ✅ Backend compilation (Rust)
|
|
||||||
- ✅ Frontend build (Vue.js)
|
|
||||||
- ✅ ISO creation
|
|
||||||
- ✅ Local and remote build support
|
|
||||||
- ✅ Smart caching (`--skip-backend`, `--skip-frontend`)
|
|
||||||
- ✅ Clean build option (`--clean`)
|
|
||||||
- ✅ Full validation
|
|
||||||
- ✅ Auto-generated flash script
|
|
||||||
|
|
||||||
### 4. **Documentation**
|
|
||||||
- ✅ `BUILD-GUIDE.md` - Comprehensive build instructions
|
|
||||||
- ✅ `BUILD-SYSTEM-SUMMARY.md` - System overview
|
|
||||||
- ✅ Updated `README.md` with build quick start
|
|
||||||
|
|
||||||
## 🔄 In Progress
|
|
||||||
|
|
||||||
### Current ISO Build
|
|
||||||
- **Status**: Running on `archipelago@192.168.1.228`
|
|
||||||
- **Progress**: Downloading Debian ISO (was at ~45% last check)
|
|
||||||
- **ETA**: ~10-15 minutes total
|
|
||||||
- **Includes**:
|
|
||||||
- Fixed auto-start (no manual prompt)
|
|
||||||
- Latest backend binary
|
|
||||||
- Latest frontend with updated Ollama icon
|
|
||||||
- SSH enabled by default
|
|
||||||
- Enhanced build reporting
|
|
||||||
|
|
||||||
## 📊 Performance Improvements
|
|
||||||
|
|
||||||
### Build Time Breakdown
|
|
||||||
|
|
||||||
| Stage | Before | After (Cached) |
|
|
||||||
|-------|--------|---------------|
|
|
||||||
| ISO Download | 15-20 min | **0 sec** (cached) |
|
|
||||||
| Backend Compile | 3-5 min | 30 sec (incremental) |
|
|
||||||
| Frontend Build | 1-2 min | 1-2 min |
|
|
||||||
| ISO Creation | 2-3 min | 2-3 min |
|
|
||||||
| **Total** | **21-30 min** | **4-6 min** |
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
|
|
||||||
| Feature | Before | After |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Build command | Multi-step manual | Single command |
|
|
||||||
| Progress visibility | Silent | Real-time progress bar |
|
|
||||||
| Cache awareness | Hidden | Explicit messages |
|
|
||||||
| Build time | Unknown | Displayed |
|
|
||||||
| Error messages | Generic | Specific with validation |
|
|
||||||
| ISO info | Basic | MD5, size, location |
|
|
||||||
| Next steps | None | Step-by-step guide |
|
|
||||||
|
|
||||||
## 🎯 Benefits
|
|
||||||
|
|
||||||
### For Development
|
|
||||||
1. **Faster iteration**: Skip unchanged components
|
|
||||||
2. **Clear feedback**: Know exactly what's building
|
|
||||||
3. **Reproducible builds**: Same command every time
|
|
||||||
4. **Easy debugging**: Clear error messages
|
|
||||||
|
|
||||||
### For Production
|
|
||||||
1. **Reliable**: Validated downloads and builds
|
|
||||||
2. **Documented**: Complete build summary
|
|
||||||
3. **Traceable**: MD5 checksums for verification
|
|
||||||
4. **Automated**: No manual steps
|
|
||||||
|
|
||||||
## 📝 Usage Examples
|
|
||||||
|
|
||||||
### Quick Build (Using Cache)
|
|
||||||
```bash
|
|
||||||
./build-iso-complete.sh --remote archipelago@192.168.1.228
|
|
||||||
# ~4-6 minutes with cached ISO
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clean Build (First Time)
|
|
||||||
```bash
|
|
||||||
./build-iso-complete.sh --remote archipelago@192.168.1.228 --clean
|
|
||||||
# ~21-30 minutes with ISO download
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend-Only Update
|
|
||||||
```bash
|
|
||||||
./build-iso-complete.sh --remote archipelago@192.168.1.228 --skip-backend
|
|
||||||
# ~3-4 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend-Only Update
|
|
||||||
```bash
|
|
||||||
./build-iso-complete.sh --remote archipelago@192.168.1.228 --skip-frontend
|
|
||||||
# ~3-4 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 Security Features
|
|
||||||
|
|
||||||
All builds include:
|
|
||||||
- ✅ SSH server with default credentials (for initial setup)
|
|
||||||
- ✅ Auto-login configured
|
|
||||||
- ✅ Password change recommended in docs
|
|
||||||
- ✅ SSH key authentication supported
|
|
||||||
|
|
||||||
## 🚀 What's Next
|
|
||||||
|
|
||||||
Once current ISO build completes:
|
|
||||||
1. Test on Dell OptiPlex
|
|
||||||
2. Verify auto-start works
|
|
||||||
3. Confirm Web UI accessible
|
|
||||||
4. Test SSH access
|
|
||||||
5. Validate all apps launch correctly
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
All improvements are documented in:
|
|
||||||
- `BUILD-GUIDE.md` - Full build instructions
|
|
||||||
- `BUILD-SYSTEM-SUMMARY.md` - System architecture
|
|
||||||
- `build-iso-complete.sh --help` - CLI help
|
|
||||||
- This file - Today's changes
|
|
||||||
|
|
||||||
## 🎉 Summary
|
|
||||||
|
|
||||||
You now have a **professional-grade build system** with:
|
|
||||||
- ✅ One-command builds
|
|
||||||
- ✅ Clear progress indicators
|
|
||||||
- ✅ Smart caching
|
|
||||||
- ✅ Build time tracking
|
|
||||||
- ✅ Comprehensive summaries
|
|
||||||
- ✅ Full documentation
|
|
||||||
- ✅ Remote build support
|
|
||||||
- ✅ Easy iteration
|
|
||||||
|
|
||||||
**Build time reduced from 30 minutes to 5 minutes** for cached builds! 🚀
|
|
||||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-03-25
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- All crypto dependencies pinned to exact versions from Cargo.lock (supply chain hardening)
|
||||||
|
- ed25519-dalek 2.1 → 2.2.0, sha2 → 0.10.9, hmac → 0.12.1, argon2 → 0.5.3, chacha20poly1305 → 0.10.1, zeroize → 1.8.2, hkdf → 0.12.4, aes-gcm → 0.10.3
|
||||||
|
- All container images pinned to exact patch versions (no more floating tags)
|
||||||
|
- postgres:15 → 15.17, redis:7 → 7.4.8, nginx:alpine → 1.29.6-alpine, uptime-kuma:1 → 1.23.17, nextcloud:29 → 29.0.16, valkey:8 → 8.1.6, mariadb:11.4 → 11.4.10, and 7 more
|
||||||
|
- DWN server pinned by SHA256 digest (only has `:main` branch tag)
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- Nostr relay connections now have 10s timeout — prevents indefinite hangs blocking RPC calls
|
||||||
|
- identity_manager.rs: publish_profile()
|
||||||
|
- nostr_discovery.rs: publish_node_revocation(), verify_revocation(), discover_archipelago_nodes()
|
||||||
|
- marketplace.rs: discover(), publish()
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- CI pipeline added (.github/workflows/ci.yml) — cargo fmt, clippy, tests + frontend type-check, build
|
||||||
|
- Update system now fetches from git.tx1138.com Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL)
|
||||||
|
- Cleaned up stale git branches (app-store, overnight/2026-03-12, overnight/2026-03-13)
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-03-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
#### Pentest Remediation (33 findings, all addressed)
|
||||||
|
- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678
|
||||||
|
- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories
|
||||||
|
- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal
|
||||||
|
- **High**: Federation peers now require cryptographic signature — unsigned peers rejected
|
||||||
|
- **High**: Login redirect XSS vulnerability fixed with proper URL validation
|
||||||
|
- **High**: Viewer role restricted to read-only node methods (was granting sign/export access)
|
||||||
|
- **High**: Backup restore/verify now validates IDs against path traversal
|
||||||
|
- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks)
|
||||||
|
- **High**: S3 backup endpoints require HTTPS and reject private IP ranges
|
||||||
|
- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id)
|
||||||
|
- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification
|
||||||
|
- **Medium**: Session token rotated after TOTP verification (prevents interception reuse)
|
||||||
|
- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains
|
||||||
|
- **Low**: CORS localhost:8100 only included in dev mode
|
||||||
|
- **Low**: CSP `unsafe-inline` removed from `script-src`
|
||||||
|
- **Low**: Content filenames validated against path separators and hidden file prefixes
|
||||||
|
- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection
|
||||||
|
- **Low**: Onion address validation enforces v3 format (56 base32 chars)
|
||||||
|
- **Low**: Router detection restricted to private IP ranges only
|
||||||
|
|
||||||
|
#### Nginx Authentication
|
||||||
|
- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks
|
||||||
|
- LND Connect info endpoint now properly authenticated
|
||||||
|
|
||||||
|
### Container Reliability
|
||||||
|
|
||||||
|
#### Memory Limits (prevents OOM crashes)
|
||||||
|
- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits
|
||||||
|
- Automatic RAM tier detection — reduced limits on 8GB machines
|
||||||
|
- Prevents a single runaway container from crashing the entire system
|
||||||
|
|
||||||
|
#### Smart Container States
|
||||||
|
- New `exited` state distinguishes crashed containers from intentionally stopped ones
|
||||||
|
- Crashed containers show red "crashed" badge with restart button
|
||||||
|
- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse)
|
||||||
|
- Restart button added next to Stop on running containers
|
||||||
|
|
||||||
|
#### Crash Recovery Improvements
|
||||||
|
- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade)
|
||||||
|
- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart
|
||||||
|
- Boot recovery uses tiered ordering: databases → core → services → apps → UIs
|
||||||
|
- Health monitor waits for boot recovery to complete before starting checks
|
||||||
|
|
||||||
|
### UI Improvements
|
||||||
|
|
||||||
|
#### Home Dashboard
|
||||||
|
- Wallet card now matches Web5 wallet display
|
||||||
|
- New Transactions modal with full history (incoming/outgoing, amounts, confirmations)
|
||||||
|
- Transactions button in header — switches to "Incoming" badge when pending transactions exist
|
||||||
|
- Dev faucet button (dev mode only) with mutable wallet state
|
||||||
|
- Fixed system stats crash (`cpu_usage_percent` field name mismatch)
|
||||||
|
|
||||||
|
#### Apps & App Details
|
||||||
|
- Container restart button (icon) next to Stop on all running apps
|
||||||
|
- Exited/crashed containers show "Restart" instead of "Start" with red styling
|
||||||
|
- Removed broken sticky header from Apps page
|
||||||
|
- Health-aware status badges throughout
|
||||||
|
|
||||||
|
#### Mesh, Cloud, Settings & More
|
||||||
|
- Mesh view overhaul with improved layout
|
||||||
|
- Glass button styling updates across components
|
||||||
|
- New BaseModal and ToggleSwitch components
|
||||||
|
- Updated translations (English + Spanish)
|
||||||
|
- Spotlight search improvements
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
#### LND Connect
|
||||||
|
- Tor hidden service now exposes LND REST port (8080) for remote wallet connections
|
||||||
|
- Fixed in ISO build script, deploy script, and live servers
|
||||||
|
|
||||||
|
#### Dev Environment
|
||||||
|
- Mock backend has mutable wallet state (faucet/send/receive actually change balances)
|
||||||
|
- Testnet stack option auto-starts Podman machine on macOS
|
||||||
|
- Boot mode simulation for testing startup screens
|
||||||
|
|
||||||
## [1.2.0] - 2026-03-14
|
## [1.2.0] - 2026-03-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
409
CLAUDE.md
409
CLAUDE.md
@@ -1,359 +1,130 @@
|
|||||||
# CLAUDE.md — Archipelago (Archy) Project Guide
|
# CLAUDE.md — Archipelago (Archy)
|
||||||
|
|
||||||
## Project Overview
|
## Overview
|
||||||
|
|
||||||
Archipelago is a **Bitcoin Node OS** — a bootable, self-sovereign personal server you flash to USB, install on hardware, and manage via a web UI. Similar to Umbrel/Start9/RaspiBlitz but custom-built with production-grade security.
|
Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI.
|
||||||
|
|
||||||
**Stack**: Rust backend + Vue 3 (Composition API) + TypeScript (strict) + Vite 7 + Tailwind CSS + Pinia + Podman
|
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 12
|
||||||
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
|
**Version**: 0.1.0 | **Target**: x86_64 and ARM64
|
||||||
**Current version**: 0.1.0
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## BETA FREEZE — ACTIVE (2026-03-18)
|
## Beta Freeze (2026-03-18)
|
||||||
|
|
||||||
**Goal: Ship a flawless beta that works perfectly on every machine we install it on.**
|
**Phase 1: Feature Testing (internal) — WE ARE HERE**
|
||||||
|
|
||||||
We are in **beta stabilization mode**. The current feature set is LOCKED. Every session must push toward this goal.
|
Feature set is LOCKED. Only: bug fixes, security hardening, ISO build fixes, UI polish, testing.
|
||||||
|
No new features, no new apps, no new deps, no scope creep.
|
||||||
|
|
||||||
### Pipeline
|
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
|
||||||
|
|
||||||
```
|
|
||||||
PHASE 1: Feature Testing (internal) ← WE ARE HERE
|
|
||||||
↓ Gate: every feature works, bugs fixed, security hardened, ISO verified
|
|
||||||
PHASE 2: User Testing (real users on real hardware we don't control)
|
|
||||||
↓ Gate: user-reported issues resolved, telemetry shows stable fleet
|
|
||||||
PHASE 3: Beta Live (public release)
|
|
||||||
```
|
|
||||||
|
|
||||||
### What IS allowed
|
|
||||||
- Bug fixes for existing features
|
|
||||||
- Security hardening and testing
|
|
||||||
- Beta telemetry / node reporting (TASK-12 — needed for user testing)
|
|
||||||
- UI/layout rearrangements (moving things around, improving flow)
|
|
||||||
- Boot screen completion (FEATURE-4 — already in progress)
|
|
||||||
- Testing all features end-to-end on fresh installs
|
|
||||||
- Performance and reliability improvements to existing code
|
|
||||||
- ISO build hardening
|
|
||||||
|
|
||||||
### What is NOT allowed
|
|
||||||
- New features (watch-only wallet, mesh balance check, etc. are POST-BETA)
|
|
||||||
- New app integrations
|
|
||||||
- New backend modules or RPC endpoints (unless fixing existing bugs or beta telemetry)
|
|
||||||
- New dependencies (unless required for beta infrastructure)
|
|
||||||
- Scope creep of any kind
|
|
||||||
|
|
||||||
### Status tracking
|
|
||||||
- **Progress tracker**: `docs/BETA-PROGRESS.md` — updated every session
|
|
||||||
- **Beta checklist**: `docs/BETA-RELEASE-CHECKLIST.md` — the acceptance criteria
|
|
||||||
- **Master plan**: `docs/MASTER_PLAN.md` — phased roadmap (Phase 1/2/3)
|
|
||||||
|
|
||||||
### Session protocol
|
|
||||||
1. Read `docs/BETA-PROGRESS.md` at start of every session
|
|
||||||
2. Report current phase and status before starting work
|
|
||||||
3. Work only on current-phase items
|
|
||||||
4. Update `docs/BETA-PROGRESS.md` at end of every session with what changed
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend local dev (mock backend on :5959, Vite on :8100)
|
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
|
||||||
cd neode-ui && npm start
|
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
|
||||||
|
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
|
||||||
# Deploy to live server (frontend + backend + restart services)
|
|
||||||
./scripts/deploy-to-target.sh --live
|
|
||||||
|
|
||||||
# Deploy to both servers
|
|
||||||
./scripts/deploy-to-target.sh --both
|
|
||||||
|
|
||||||
# Frontend build (outputs to web/dist/neode-ui/)
|
|
||||||
cd neode-ui && npm run build
|
|
||||||
|
|
||||||
# Type-check frontend
|
|
||||||
cd neode-ui && npm run type-check
|
|
||||||
|
|
||||||
# Rust checks (run on dev server, NOT macOS)
|
|
||||||
cargo clippy --all-targets --all-features
|
|
||||||
cargo fmt --all
|
|
||||||
cargo test --all-features
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Dev server: `http://192.168.1.228` | Local frontend: `http://localhost:8100` (password: `password123`)
|
## Infrastructure
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|------|-------|
|
||||||
|
| Dev server | `192.168.1.228` (SSH key: `~/.ssh/archipelago-deploy`) |
|
||||||
|
| Secondary | `192.168.1.198` |
|
||||||
|
| Git remote | `git.tx1138.com` (remote name: `tx1138`) |
|
||||||
|
| App registry | `80.71.235.15:3000/archipelago/` (HTTP, insecure) |
|
||||||
|
| CI runner | act_runner on .228, workflow: `.gitea/workflows/build-iso.yml` |
|
||||||
|
| ISO builds | FileBrowser at `http://192.168.1.228:8083` → Builds/ |
|
||||||
|
| SSH creds | Gitignored `scripts/deploy-config.sh` |
|
||||||
|
| Web password | `password123` |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Debian 12 (Bookworm)
|
Debian 12
|
||||||
├── Podman (rootless containers)
|
├── Podman (rootless, user archipelago)
|
||||||
├── Nginx (port 80 → proxies /rpc/, /ws/, /health to backend)
|
├── Nginx (80/443 → backend, app proxies)
|
||||||
├── Rust Backend (core/) — binary on port 5678
|
├── Rust Backend (core/) on 127.0.0.1:5678
|
||||||
│ ├── core/archipelago/ — Main binary, RPC endpoints
|
│ ├── core/archipelago/ — Binary, RPC, auth, sessions
|
||||||
│ ├── core/container/ — PodmanClient, manifest parser, dependency resolver, health monitor
|
│ └── core/container/ — PodmanClient, manifests, health
|
||||||
│ ├── core/security/ — AppArmor profiles, secrets manager, Cosign image verifier
|
|
||||||
│ ├── core/performance/ — Resource manager
|
|
||||||
│ └── core/parmanode/ — Parmanode compatibility layer
|
|
||||||
└── Vue.js UI (neode-ui/)
|
└── Vue.js UI (neode-ui/)
|
||||||
├── src/api/ — RPC client (rpc-client.ts), WebSocket, container client
|
├── src/api/rpc-client.ts — All backend communication
|
||||||
├── src/stores/ — Pinia stores
|
├── src/stores/ — Pinia state
|
||||||
├── src/views/ — Page components
|
├── src/views/ — Pages
|
||||||
├── src/components/ — Reusable components
|
└── src/style.css — ALL styling (global classes only)
|
||||||
├── src/router/ — Vue Router
|
|
||||||
├── src/types/ — TypeScript type definitions
|
|
||||||
└── src/style.css — Global styles + Tailwind utilities
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Paths (Server)
|
**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary)
|
||||||
|
|
||||||
- App data: `/var/lib/archipelago/{app-id}/`
|
## Critical Rules
|
||||||
- Secrets: `/var/lib/archipelago/secrets/{app-id}/` (encrypted)
|
|
||||||
- Frontend: `/opt/archipelago/web-ui/`
|
|
||||||
- Backend binary: `/usr/local/bin/archipelago`
|
|
||||||
- Systemd service: `/etc/systemd/system/archipelago.service`
|
|
||||||
- Nginx config: `/etc/nginx/sites-available/archipelago`
|
|
||||||
|
|
||||||
## CRITICAL Workflow Rules
|
1. **Never build Rust on macOS** — deploy script handles cross-compilation via rsync + remote build
|
||||||
|
2. **Always deploy after changes** — `./scripts/deploy-to-target.sh --live`
|
||||||
|
3. **Frontend builds to `web/dist/neode-ui/`** — not `neode-ui/dist/`
|
||||||
|
4. **Container images**: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, never hardcoded registry paths.
|
||||||
|
5. **Type-check before committing** — `cd neode-ui && npx vue-tsc -b --noEmit`
|
||||||
|
|
||||||
### 1. NEVER Build Rust on macOS for Linux
|
## Frontend
|
||||||
|
|
||||||
Always rsync source to the Linux dev server and build there. Building on macOS and copying the binary causes Exec format errors.
|
- `<script setup lang="ts">` always — no Options API
|
||||||
|
- Global CSS in `style.css` — **never inline Tailwind**
|
||||||
|
- `.glass-button` for ALL buttons — `.gradient-button` is BANNED
|
||||||
|
- `.glass-card` for containers, `.path-option-card` for interactive cards
|
||||||
|
- `translateZ(0)` + `isolation: isolate` on glass elements (Chromium compositor fix)
|
||||||
|
- Pinia for state, typed RPC client, handle loading/error/empty states
|
||||||
|
|
||||||
|
## Backend (Rust)
|
||||||
|
|
||||||
|
- No `unwrap()`/`expect()` — use `?` with `.context()`
|
||||||
|
- `tracing` for logging, never `println!` or log secrets
|
||||||
|
- Backend binds `127.0.0.1` only — nginx handles external access
|
||||||
|
- Validate all input before path construction — reject `..`, `/`, null bytes
|
||||||
|
- `tokio` runtime, timeouts on all external ops
|
||||||
|
|
||||||
|
## Security (Post-Pentest)
|
||||||
|
|
||||||
|
- RBAC: explicit method allowlists, never prefix matching
|
||||||
|
- Session cookies: `SameSite=Lax; HttpOnly; Path=/`
|
||||||
|
- Rate-limit auth endpoints, rotate tokens after privilege escalation
|
||||||
|
- Validate redirect URLs with `isLocalRedirect()`, never `v-html` with user input
|
||||||
|
- Container security: drop ALL caps, add only required, `no-new-privileges`, memory limits, health checks
|
||||||
|
- See `.claude/rules/` for detailed crypto, API, container, and Bitcoin rules
|
||||||
|
|
||||||
|
## ISO Build & CI
|
||||||
|
|
||||||
|
CI builds on every push to `main` via git.tx1138.com Actions.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy does this automatically:
|
# Manual build on .228:
|
||||||
./scripts/deploy-to-target.sh --live
|
ssh archipelago@192.168.1.228
|
||||||
|
cd ~/archy/image-recipe
|
||||||
|
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Always Deploy After Changes
|
**Debugging fresh installs** — SSH in and check:
|
||||||
|
|
||||||
After editing code (frontend, backend, scripts, or configs), deploy to the live server. Do not leave deployment to the user.
|
|
||||||
|
|
||||||
### 3. Frontend Build Output Path
|
|
||||||
|
|
||||||
Frontend builds to `web/dist/neode-ui/` — NOT `neode-ui/dist/`.
|
|
||||||
|
|
||||||
### 4. Deploy-Test-Fix Loop
|
|
||||||
|
|
||||||
1. Make the change
|
|
||||||
2. Deploy with `./scripts/deploy-to-target.sh --live`
|
|
||||||
3. Test at http://192.168.1.228
|
|
||||||
4. If broken, fix and redeploy — repeat until working
|
|
||||||
5. End loop only when everything works
|
|
||||||
|
|
||||||
### 5. SSH Access
|
|
||||||
|
|
||||||
- **Primary**: `archipelago@192.168.1.228` — password: `EwPDR8q45l0Upx@`
|
|
||||||
- **Secondary**: `archipelago@192.168.1.198`
|
|
||||||
- Credentials stored in gitignored `scripts/deploy-config.sh`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
|
cat /var/log/archipelago-install.log # Full installer output
|
||||||
|
cat /var/log/archipelago-first-boot-diagnostics.log # Service status, nginx, LUKS, etc.
|
||||||
|
sudo archipelago-diagnostics # Re-run diagnostics anytime
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Rules (Vue.js + TypeScript)
|
**Kiosk**: X11 on VT7, console on VT1. `Ctrl+Alt+F1` for terminal, `Ctrl+Alt+F7` for kiosk.
|
||||||
|
Toggle: `sudo archipelago-kiosk enable|disable|toggle`
|
||||||
### Component Standards
|
|
||||||
|
|
||||||
- **Always** `<script setup lang="ts">` — never Options API, never plain JS
|
|
||||||
- **Pinia** for all state management — focused single-purpose stores
|
|
||||||
- **TypeScript strict mode** — no `any`, use `unknown` or proper types
|
|
||||||
- Export types from dedicated `.types.ts` files
|
|
||||||
- Use type guards for runtime type checking
|
|
||||||
|
|
||||||
### Styling — Global Classes Only
|
|
||||||
|
|
||||||
- **ALWAYS** create global utility classes in `neode-ui/src/style.css`
|
|
||||||
- **NEVER** use inline Tailwind classes directly in components
|
|
||||||
- Use semantic class names: `.glass-card`, `.glass-button`, `.gradient-button`, `.path-option-card`
|
|
||||||
|
|
||||||
### API Client Rules
|
|
||||||
|
|
||||||
- Use `@/api/rpc-client.ts` for RPC calls, `@/api/container-client.ts` for containers
|
|
||||||
- **NEVER** hardcode API endpoints — use environment variables
|
|
||||||
- Handle loading states, error states, retry logic for all async operations
|
|
||||||
|
|
||||||
### CSS Class Hierarchy
|
|
||||||
|
|
||||||
| Class | Use | Hover |
|
|
||||||
|-------|-----|-------|
|
|
||||||
| `.path-option-card` | Section containers, interactive cards (Settings-style) | Lifts -2px |
|
|
||||||
| `.glass-card` | Content containers, modals, panels | No |
|
|
||||||
| `.info-card` | Status badges, metric displays | No |
|
|
||||||
| `.info-card-button` | Action buttons inside info sections | Lifts, brightens |
|
|
||||||
| `bg-black/20 rounded-xl border border-white/10` | Info sub-cards inside sections | No |
|
|
||||||
| `bg-white/5` | Simple read-only info rows | No |
|
|
||||||
| `.glass-button` | ALL buttons (primary and secondary) | Subtle brighten |
|
|
||||||
| `.path-action-button` | Large action buttons (Logout, Continue) | Lifts -2px |
|
|
||||||
|
|
||||||
### BANNED Classes — Do NOT Use
|
|
||||||
- **`.gradient-button`** — REMOVED. Use `.glass-button` instead. The gradient style breaks the clean glass aesthetic.
|
|
||||||
- **`.gradient-card`** / **`.gradient-card-dark`** — REMOVED. Use `.glass-card` or `.path-option-card` instead.
|
|
||||||
|
|
||||||
### Design Tokens
|
|
||||||
|
|
||||||
- **Font**: Avenir Next (primary), Montserrat (`font-archipelago`)
|
|
||||||
- **Spacing**: 4px grid system, 16px default padding
|
|
||||||
- **Glassmorphism**: `background: rgba(0,0,0,0.60)`, `backdrop-filter: blur(24px)`, `inset 0 1px 0 rgba(255,255,255,0.22)`
|
|
||||||
- **Transitions**: `all 0.3s ease` standard, `translateY(-2px)` hover, `translateY(1px)` active
|
|
||||||
- **Accent orange** (Bitcoin): `#fb923c` — `#f59e0b`
|
|
||||||
- **Green** (success): `#4ade80` | **Red** (danger): `#ef4444` | **Blue** (info): `#3b82f6`
|
|
||||||
- **Text**: `rgba(255,255,255,0.9)` primary, `rgba(255,255,255,0.6-0.7)` muted
|
|
||||||
|
|
||||||
### Tailwind Custom Values
|
|
||||||
|
|
||||||
- Blur: `backdrop-blur-glass` (18px), `backdrop-blur-glass-strong` (24px)
|
|
||||||
- Colors: `glass-dark` (0,0,0,0.35), `glass-darker` (0,0,0,0.6), `glass-border` (255,255,255,0.18)
|
|
||||||
- Shadows: `shadow-glass`, `shadow-glass-inset`
|
|
||||||
|
|
||||||
## Backend Rules (Rust)
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- **No `unwrap()` or `expect()` in production code** — use `?` operator
|
|
||||||
- `thiserror` for library error types, `anyhow` for application errors
|
|
||||||
- Custom error types per module: `{module}::Error`
|
|
||||||
- Include context: `.context("What failed and why")`
|
|
||||||
|
|
||||||
### RPC Endpoints
|
|
||||||
|
|
||||||
- Use `rpc_toolkit::command` macro for all endpoints
|
|
||||||
- Use `#[context] ctx: RpcContext` for context
|
|
||||||
- Return `Result<T, Error>` — validate all inputs before processing
|
|
||||||
|
|
||||||
### Async & Runtime
|
|
||||||
|
|
||||||
- `tokio` runtime only — never mix with other async runtimes
|
|
||||||
- Set timeouts on all external operations
|
|
||||||
- Use `select!` for racing futures with timeouts
|
|
||||||
- Handle shutdown gracefully with cancellation tokens
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
|
|
||||||
- New modules in `core/{module-name}/`, add to `core/Cargo.toml` members
|
|
||||||
- `snake_case` for all modules/files
|
|
||||||
- Run `cargo clippy --all-targets --all-features` and `cargo fmt --all` before commits
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
- Use `tracing` for structured logging — never `println!`
|
|
||||||
- Never log secrets, passwords, keys, or tokens
|
|
||||||
- Include context: `tracing::info!(user_id = %id, "Action")`
|
|
||||||
|
|
||||||
## Container & Security
|
|
||||||
|
|
||||||
### App Manifests
|
|
||||||
|
|
||||||
- All manifests in `apps/{app-id}/manifest.yml`
|
|
||||||
- Follow spec in `docs/app-manifest-spec.md`
|
|
||||||
- Use `archipelago_container::PodmanClient` — **NEVER** call Docker directly
|
|
||||||
|
|
||||||
### Security Requirements (Non-Negotiable)
|
|
||||||
|
|
||||||
- **ALWAYS** `readonly_root: true` unless explicitly needed
|
|
||||||
- **ALWAYS** drop all capabilities, add only required ones
|
|
||||||
- **ALWAYS** run as non-root user (UID > 1000)
|
|
||||||
- **ALWAYS** `no-new-privileges: true`
|
|
||||||
- **NEVER** use `latest` tag — pin specific image versions
|
|
||||||
- **NEVER** hardcode secrets — use `core/security/secrets_manager.rs`
|
|
||||||
|
|
||||||
### App Icons
|
|
||||||
|
|
||||||
Single source of truth: `neode-ui/public/assets/img/app-icons/`
|
|
||||||
Naming: `{app-id}.{png|webp|svg}` — do not duplicate elsewhere.
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
- Zero compiler warnings (Rust and TypeScript)
|
|
||||||
- Zero linter errors (clippy, eslint)
|
|
||||||
- Functions under 50 lines, single responsibility
|
|
||||||
- Comment WHY not WHAT — code should be self-documenting
|
|
||||||
- Remove dead code entirely — never comment it out
|
|
||||||
- No `TODO`/`FIXME` in commits — fix now or create issues
|
|
||||||
- Workspace-relative paths only — **NEVER** hardcode `/Users/dorian/...`
|
|
||||||
|
|
||||||
## Git Conventions
|
|
||||||
|
|
||||||
### Commit Format
|
|
||||||
|
|
||||||
```
|
|
||||||
type: description
|
|
||||||
```
|
|
||||||
|
|
||||||
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
|
||||||
|
|
||||||
### Rules
|
|
||||||
|
|
||||||
- Atomic commits — one logical change per commit
|
|
||||||
- `main` branch always production-ready
|
|
||||||
- Feature branches: `feature/description`, bug fixes: `fix/description`
|
|
||||||
- Never commit secrets, `.env` files, or credentials
|
|
||||||
- Tag releases: `v1.2.3` (SemVer)
|
|
||||||
|
|
||||||
## App Integration Checklist
|
## App Integration Checklist
|
||||||
|
|
||||||
When adding or fixing apps, **every file below must be checked**. Missing any one causes failures on fresh installs.
|
When adding/fixing apps, check ALL of these:
|
||||||
|
- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps
|
||||||
|
- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry
|
||||||
|
- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS)
|
||||||
|
- `scripts/image-versions.sh` — pinned image version
|
||||||
|
- `scripts/first-boot-containers.sh` — first boot creation
|
||||||
|
- `scripts/deploy-to-target.sh` — deploy logic
|
||||||
|
|
||||||
### Backend (Rust)
|
## Git
|
||||||
|
|
||||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — `get_app_config()`: ports, volumes, env vars, custom args
|
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
|
||||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — `needs_archy_net`: add if app needs container DNS
|
Push to: `git push tx1138 main`
|
||||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — `get_app_capabilities()`: add required caps (CHOWN, etc.)
|
|
||||||
- [ ] `core/archipelago/src/api/rpc/package.rs` — dependency checks (e.g., electrs requires bitcoin)
|
|
||||||
- [ ] `core/archipelago/src/container/docker_packages.rs` — `get_app_metadata()`: title, description, icon, repo
|
|
||||||
- [ ] `core/archipelago/src/container/docker_packages.rs` — UI address mapping (e.g., `http://localhost:50002`)
|
|
||||||
|
|
||||||
### Frontend (Vue)
|
|
||||||
|
|
||||||
- [ ] `neode-ui/src/views/Marketplace.vue` — `getCuratedAppList()`: marketplace entry with dockerImage
|
|
||||||
- [ ] `neode-ui/src/stores/appLauncher.ts` — port-to-proxy mapping (if app has custom UI port)
|
|
||||||
- [ ] `neode-ui/src/views/AppDetails.vue` — route ID mapping (if app ID differs from container name)
|
|
||||||
|
|
||||||
### Nginx
|
|
||||||
|
|
||||||
- [ ] `image-recipe/configs/nginx-archipelago.conf` — `/app/{id}/` proxy in HTTP block
|
|
||||||
- [ ] `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` — `/app/{id}/` proxy in HTTPS block
|
|
||||||
- [ ] Any custom status endpoints (e.g., `/electrs-status`) proxied before the SPA catch-all
|
|
||||||
|
|
||||||
### Deploy & First Boot
|
|
||||||
|
|
||||||
- [ ] `scripts/deploy-to-target.sh` — container creation/update logic
|
|
||||||
- [ ] `scripts/first-boot-containers.sh` — container created on fresh ISO install
|
|
||||||
- [ ] Custom UI containers (e.g., electrs-ui): built and started in both deploy and first-boot
|
|
||||||
|
|
||||||
### ISO Build
|
|
||||||
|
|
||||||
- [ ] `image-recipe/build-auto-installer-iso.sh` — `CAPTURE_PATTERNS`: image captured from live server
|
|
||||||
- [ ] `image-recipe/build-auto-installer-iso.sh` — `CONTAINER_IMAGES`: fallback image pulled from registry
|
|
||||||
- [ ] `image-recipe/build-auto-installer-iso.sh` — docker UI source files bundled for build fallback
|
|
||||||
- [ ] `image-recipe/build-auto-installer-iso.sh` — installer copies files to target disk
|
|
||||||
|
|
||||||
### Runtime Verification
|
|
||||||
|
|
||||||
- [ ] Test the app UI loads on its configured port
|
|
||||||
- [ ] Auto-connect dependencies (Bitcoin RPC, LND, etc.) — apps must work out of the box
|
|
||||||
- [ ] Most apps launch in iframe; BTCPay (23000) and Home Assistant (8123) open in new tab (X-Frame-Options)
|
|
||||||
|
|
||||||
## ISO Build
|
|
||||||
|
|
||||||
Build on the target server (has all dependencies):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh archipelago@192.168.1.228
|
|
||||||
cd ~/archy/image-recipe
|
|
||||||
sudo ./build-auto-installer-iso.sh
|
|
||||||
# Result: results/archipelago-auto-installer-*.iso
|
|
||||||
```
|
|
||||||
|
|
||||||
After testing on live server, always update ISO build to include changes. Sync system configs:
|
|
||||||
- `archipelago.service` → `image-recipe/configs/`
|
|
||||||
- `nginx-archipelago.conf` → `image-recipe/configs/`
|
|
||||||
|
|
||||||
## Key Documentation
|
|
||||||
|
|
||||||
- `docs/architecture.md` — System architecture
|
|
||||||
- `docs/current-state.md` — Current development phase
|
|
||||||
- `docs/development-setup.md` — Local dev setup
|
|
||||||
- `docs/app-manifest-spec.md` — YAML manifest spec
|
|
||||||
- `BUILD-GUIDE.md` — ISO build guide
|
|
||||||
- `DEPLOYMENT.md` — Deployment details
|
|
||||||
- `CHANGELOG.md` — Version history
|
|
||||||
|
|||||||
417
DEPLOYMENT.md
417
DEPLOYMENT.md
@@ -1,417 +0,0 @@
|
|||||||
# Archipelago Deployment & Build Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document captures all the critical configurations and fixes needed to build Archipelago from the live development server state.
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-03
|
|
||||||
**Dev Server:** archipelago@192.168.1.228
|
|
||||||
**Server Disk:** 1.8TB NVMe (1.7TB free)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Backend Fixes
|
|
||||||
|
|
||||||
### 1. Podman Container Detection (REQUIRED)
|
|
||||||
|
|
||||||
**Issue:** Backend runs as non-root user but containers are started with `sudo podman` (root context).
|
|
||||||
|
|
||||||
**Fix Applied:** Modified `/core/container/src/podman_client.rs` to use `sudo podman`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn podman_async(&self) -> TokioCommand {
|
|
||||||
// Always use sudo podman to access system-wide containers
|
|
||||||
let mut cmd = TokioCommand::new("sudo");
|
|
||||||
cmd.arg("podman");
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server Configuration:** Added passwordless sudo for podman:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# /etc/sudoers.d/archipelago-podman
|
|
||||||
archipelago ALL=(ALL) NOPASSWD: /usr/bin/podman
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. IndeedHub Metadata in Backend
|
|
||||||
|
|
||||||
**Location:** `/core/archipelago/src/container/docker_packages.rs`
|
|
||||||
|
|
||||||
Added IndeedHub to the `get_app_metadata()` function:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
"indeedhub" => AppMetadata {
|
|
||||||
title: "IndeedHub".to_string(),
|
|
||||||
description: "Decentralized media streaming platform".to_string(),
|
|
||||||
icon: "/assets/img/app-icons/indeedhub.png".to_string(),
|
|
||||||
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nginx Configuration
|
|
||||||
|
|
||||||
### HTTP + HTTPS Setup (with self-signed certs)
|
|
||||||
|
|
||||||
**Location:** `/etc/nginx/sites-available/default`
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
# Redirect HTTP to HTTPS
|
|
||||||
server {
|
|
||||||
listen 80 default_server;
|
|
||||||
listen [::]:80 default_server;
|
|
||||||
server_name _;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTPS server
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2 default_server;
|
|
||||||
listen [::]:443 ssl http2 default_server;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/archipelago.crt;
|
|
||||||
ssl_certificate_key /etc/nginx/ssl/archipelago.key;
|
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
|
|
||||||
root /opt/archipelago/web-ui;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
location /rpc/ {
|
|
||||||
proxy_pass http://localhost:5678/rpc/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /ws/ {
|
|
||||||
proxy_pass http://localhost:5678/ws/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://localhost:5678/health;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL Certificate Generation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /etc/nginx/ssl
|
|
||||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
|
||||||
-keyout /etc/nginx/ssl/archipelago.key \
|
|
||||||
-out /etc/nginx/ssl/archipelago.crt \
|
|
||||||
-subj "/C=US/ST=State/L=City/O=Archipelago/CN=archipelago.local"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Systemd Services
|
|
||||||
|
|
||||||
### Archipelago Backend Service
|
|
||||||
|
|
||||||
**Location:** `/etc/systemd/system/archipelago.service`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Archipelago Backend
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=archipelago
|
|
||||||
Group=archipelago
|
|
||||||
ExecStart=/usr/local/bin/archipelago
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
Environment="RUST_LOG=debug"
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Container Deployments
|
|
||||||
|
|
||||||
### Bitcoin Knots (Full Node)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /var/lib/archipelago/bitcoin
|
|
||||||
|
|
||||||
sudo podman run -d \
|
|
||||||
--name bitcoin-knots \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 8332:8332 \
|
|
||||||
-p 8333:8333 \
|
|
||||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
|
||||||
--label "com.archipelago.app=bitcoin-knots" \
|
|
||||||
--label "com.archipelago.title=Bitcoin Knots" \
|
|
||||||
--label "com.archipelago.version=28.1" \
|
|
||||||
--label "com.archipelago.category=bitcoin" \
|
|
||||||
--label "com.archipelago.description.short=Full Bitcoin node implementation" \
|
|
||||||
--label "com.archipelago.description.long=Bitcoin Knots is a derivative of Bitcoin Core with additional features and bug fixes. Maintain the full blockchain and validate all transactions." \
|
|
||||||
--label "com.archipelago.license=MIT" \
|
|
||||||
--label "com.archipelago.icon=/assets/img/app-icons/bitcoin-knots.webp" \
|
|
||||||
--label "com.archipelago.port=8332" \
|
|
||||||
--label "com.archipelago.repo=https://github.com/bitcoinknots/bitcoin" \
|
|
||||||
docker.io/bitcoinknots/bitcoin:latest \
|
|
||||||
-server=1 \
|
|
||||||
-txindex=1 \
|
|
||||||
-rpcallowip=0.0.0.0/0 \
|
|
||||||
-rpcbind=0.0.0.0:8332 \
|
|
||||||
-rpcuser=archipelago \
|
|
||||||
-rpcpassword=archipelago123 \
|
|
||||||
-dbcache=4096
|
|
||||||
```
|
|
||||||
|
|
||||||
### IndeedHub (Example app deployment)
|
|
||||||
|
|
||||||
See `/Users/dorian/Projects/Indeedhub Prototype/deploy-to-archipelago.sh`
|
|
||||||
|
|
||||||
**Key Requirements:**
|
|
||||||
- Must include `com.archipelago.*` labels for proper detection
|
|
||||||
- Port mapping must be explicit
|
|
||||||
- Restart policy: `unless-stopped`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build Process for Beta Release
|
|
||||||
|
|
||||||
### 1. Capture Live Server State
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/dorian/Projects/archy/image-recipe
|
|
||||||
|
|
||||||
# Capture from dev server (default)
|
|
||||||
DEV_SERVER=archipelago@192.168.1.228 ./build-auto-installer-iso.sh
|
|
||||||
|
|
||||||
# Or build from source
|
|
||||||
BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. What Gets Captured
|
|
||||||
|
|
||||||
The auto-installer script captures:
|
|
||||||
|
|
||||||
- **Backend Binary:** `/usr/local/bin/archipelago`
|
|
||||||
- **Frontend Assets:** `/opt/archipelago/web-ui/`
|
|
||||||
- **Nginx Configuration:** `/etc/nginx/sites-available/default`
|
|
||||||
- **SSL Certificates:** `/etc/nginx/ssl/`
|
|
||||||
- **Systemd Service:** `/etc/systemd/system/archipelago.service`
|
|
||||||
- **Sudoers Config:** `/etc/sudoers.d/archipelago-podman`
|
|
||||||
|
|
||||||
**NOTE:** Containers are NOT captured in the ISO - they must be deployed after installation.
|
|
||||||
|
|
||||||
### 3. Critical Auto-Installer Fix
|
|
||||||
|
|
||||||
**Location:** `/image-recipe/build-auto-installer-iso.sh` (line ~850)
|
|
||||||
|
|
||||||
The auto-start script MUST NOT check `[ ! -t 0 ]` (non-interactive check):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# CORRECT (in z99-archipelago-installer.sh):
|
|
||||||
if [ -n "$INSTALLER_STARTED" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# WRONG (will fail on auto-login):
|
|
||||||
# if [ -n "$INSTALLER_STARTED" ] || [ ! -t 0 ]; then
|
|
||||||
```
|
|
||||||
|
|
||||||
This was causing the installer to hang at `user@debian:~$` prompt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies Required on Build Machine
|
|
||||||
|
|
||||||
### For Building ISOs (Mac/Linux):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker or Podman (for rootfs creation)
|
|
||||||
brew install podman
|
|
||||||
# OR
|
|
||||||
brew install docker
|
|
||||||
|
|
||||||
# ISO creation tools
|
|
||||||
brew install xorriso # Mac
|
|
||||||
# OR
|
|
||||||
apt install xorriso # Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Server Runtime:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Debian 12 (Bookworm) base
|
|
||||||
apt update && apt install -y \
|
|
||||||
nginx \
|
|
||||||
podman \
|
|
||||||
build-essential \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
curl \
|
|
||||||
rsync
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/dorian/Projects/archy/neode-ui
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Output goes to: ../web/dist/neode-ui/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deploy to server:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -avz --delete ../web/dist/neode-ui/ archipelago@192.168.1.228:/opt/archipelago/web-ui/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/dorian/Projects/archy/core/archipelago
|
|
||||||
|
|
||||||
# Build release binary
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Binary location: ../target/release/archipelago
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deploy to server:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scp ../target/release/archipelago archipelago@192.168.1.228:/tmp/
|
|
||||||
ssh archipelago@192.168.1.228 'sudo systemctl stop archipelago && \
|
|
||||||
sudo mv /tmp/archipelago /usr/local/bin/archipelago && \
|
|
||||||
sudo chmod +x /usr/local/bin/archipelago && \
|
|
||||||
sudo systemctl start archipelago'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist (Pre-Release)
|
|
||||||
|
|
||||||
- [ ] Backend detects all running containers
|
|
||||||
- [ ] Frontend loads and connects to backend WebSocket
|
|
||||||
- [ ] Apps show in "My Apps" with correct status
|
|
||||||
- [ ] App Store shows containers with "Installed" badge
|
|
||||||
- [ ] Bitcoin node is syncing blockchain
|
|
||||||
- [ ] Nginx serves frontend correctly
|
|
||||||
- [ ] RPC/WebSocket proxying works
|
|
||||||
- [ ] Auto-installer ISO boots and installs
|
|
||||||
- [ ] Post-install: System boots to login screen
|
|
||||||
- [ ] Web UI accessible at http://server-ip
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
### Port 443 Not Binding (Post-Reinstall)
|
|
||||||
|
|
||||||
After fresh install, HTTPS (port 443) may not bind even with correct nginx config. **Workaround:** Use HTTP only initially, investigate nginx/systemd socket issues.
|
|
||||||
|
|
||||||
### Browser HTTPS Auto-Upgrade
|
|
||||||
|
|
||||||
Browsers (especially Brave/Chrome) aggressively upgrade to HTTPS. Users may need to:
|
|
||||||
- Clear site data
|
|
||||||
- Disable "HTTPS-Only Mode"
|
|
||||||
- Use `http://` prefix explicitly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Locations Summary
|
|
||||||
|
|
||||||
| Component | Dev Server Location | ISO Build Captures |
|
|
||||||
|-----------|-------------------|-------------------|
|
|
||||||
| Backend Binary | `/usr/local/bin/archipelago` | ✅ Yes |
|
|
||||||
| Frontend Assets | `/opt/archipelago/web-ui/` | ✅ Yes |
|
|
||||||
| Nginx Config | `/etc/nginx/sites-available/default` | ✅ Yes |
|
|
||||||
| SSL Certs | `/etc/nginx/ssl/` | ✅ Yes |
|
|
||||||
| Systemd Service | `/etc/systemd/system/archipelago.service` | ✅ Yes |
|
|
||||||
| Sudoers | `/etc/sudoers.d/archipelago-podman` | ✅ Yes |
|
|
||||||
| Container Data | `/var/lib/archipelago/` | ❌ No - too large |
|
|
||||||
| Bitcoin Blockchain | `/var/lib/archipelago/bitcoin/` | ❌ No - user downloads |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Version Control
|
|
||||||
|
|
||||||
**Important Changes to Track:**
|
|
||||||
|
|
||||||
1. `/core/container/src/podman_client.rs` - sudo podman fix
|
|
||||||
2. `/core/archipelago/src/container/docker_packages.rs` - app metadata
|
|
||||||
3. `/neode-ui/src/utils/dummyApps.ts` - frontend app definitions
|
|
||||||
4. `/image-recipe/build-auto-installer-iso.sh` - auto-start fix
|
|
||||||
|
|
||||||
**Commit before building beta:**
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "Prepare for beta release: podman detection, IndeedHub metadata, auto-installer fixes"
|
|
||||||
git tag v0.1.0-beta.1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Emergency Recovery
|
|
||||||
|
|
||||||
If the backend fails to detect containers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify sudoers file exists
|
|
||||||
cat /etc/sudoers.d/archipelago-podman
|
|
||||||
|
|
||||||
# Test manual detection
|
|
||||||
sudo podman ps --format json
|
|
||||||
|
|
||||||
# Check backend logs
|
|
||||||
sudo journalctl -u archipelago -f
|
|
||||||
|
|
||||||
# Restart backend
|
|
||||||
sudo systemctl restart archipelago
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
Development Server: archipelago@192.168.1.228
|
|
||||||
Password: `archipelago`
|
|
||||||
Web UI: http://192.168.1.228 (or https with self-signed cert warning)
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
# Mac Development Setup - What You Need
|
|
||||||
|
|
||||||
## Current Situation
|
|
||||||
|
|
||||||
You develop Archipelago on a **remote Debian server** (192.168.1.228), not locally on your Mac.
|
|
||||||
|
|
||||||
Your Mac is used for:
|
|
||||||
- ✅ **Editing code** (VSCode/Cursor)
|
|
||||||
- ✅ **Git operations** (commit, push, pull)
|
|
||||||
- ✅ **Deploying to remote** (`deploy-to-target.sh`)
|
|
||||||
- ✅ **Building ISOs** (occasionally)
|
|
||||||
|
|
||||||
Your Mac is NOT used for:
|
|
||||||
- ❌ Running containers locally
|
|
||||||
- ❌ Building Rust locally
|
|
||||||
- ❌ Running the backend locally
|
|
||||||
- ❌ Running the frontend dev server locally
|
|
||||||
|
|
||||||
## Disk Usage Analysis
|
|
||||||
|
|
||||||
### 🔴 Can Delete (Total: ~66 GB)
|
|
||||||
|
|
||||||
1. **Docker Desktop: 53 GB**
|
|
||||||
- You're not running containers locally
|
|
||||||
- All containers run on 192.168.1.228
|
|
||||||
- Safe to completely uninstall
|
|
||||||
|
|
||||||
2. **Rust Build Cache: 1.6 GB** (`core/target/`)
|
|
||||||
- Builds happen on remote server via `deploy-to-target.sh`
|
|
||||||
- Rust compiler still needed for occasional local builds
|
|
||||||
- Cache rebuilds automatically
|
|
||||||
|
|
||||||
3. **ISO Build Artifacts: 8.6 GB** (`image-recipe/build/`)
|
|
||||||
- Temporary files from ISO building
|
|
||||||
- Recreated when you build a new ISO
|
|
||||||
- Safe to delete
|
|
||||||
|
|
||||||
4. **Old ISO Files: ~3 GB** (`image-recipe/results/`)
|
|
||||||
- Keep latest ISO only (~500MB)
|
|
||||||
- Delete old versions
|
|
||||||
|
|
||||||
### 🟢 Keep These Tools
|
|
||||||
|
|
||||||
1. **Rust/Cargo** ✅
|
|
||||||
- For occasional local builds
|
|
||||||
- For `deploy-to-target.sh` (builds before deploying)
|
|
||||||
- Size: ~200 MB
|
|
||||||
|
|
||||||
2. **Node.js/npm** ✅
|
|
||||||
- For frontend builds in `deploy-to-target.sh`
|
|
||||||
- For editing with IDE autocomplete
|
|
||||||
- Size: ~100 MB
|
|
||||||
|
|
||||||
3. **Git** ✅
|
|
||||||
- Version control
|
|
||||||
- Essential
|
|
||||||
|
|
||||||
4. **SSH** ✅
|
|
||||||
- Remote server access
|
|
||||||
- Essential for deployment
|
|
||||||
|
|
||||||
### ⚠️ Optional (You Choose)
|
|
||||||
|
|
||||||
1. **Podman** (~100 MB)
|
|
||||||
- Currently installed but not used
|
|
||||||
- Could remove: `brew uninstall podman`
|
|
||||||
- You use Podman on the *remote server*, not locally
|
|
||||||
|
|
||||||
2. **xorriso, p7zip** (ISO build tools)
|
|
||||||
- Only needed if building ISOs locally
|
|
||||||
- Can reinstall when needed: `brew install xorriso p7zip`
|
|
||||||
|
|
||||||
## Recommended Setup
|
|
||||||
|
|
||||||
### Minimal Mac Setup (Recommended)
|
|
||||||
```
|
|
||||||
✅ VSCode/Cursor (code editing)
|
|
||||||
✅ Git (version control)
|
|
||||||
✅ SSH (remote access)
|
|
||||||
✅ Rust/Cargo (for deploy script)
|
|
||||||
✅ Node.js/npm (for deploy script)
|
|
||||||
✅ One latest ISO file (~500 MB)
|
|
||||||
❌ NO Docker Desktop
|
|
||||||
❌ NO local containers
|
|
||||||
❌ NO build artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Total disk usage: ~500 MB + source code**
|
|
||||||
|
|
||||||
### Your Development Workflow
|
|
||||||
```bash
|
|
||||||
# 1. Edit code locally on Mac
|
|
||||||
vim core/archipelago/src/...
|
|
||||||
vim neode-ui/src/...
|
|
||||||
|
|
||||||
# 2. Deploy to remote server
|
|
||||||
./scripts/deploy-to-target.sh --live
|
|
||||||
|
|
||||||
# 3. Test on remote server
|
|
||||||
open http://192.168.1.228
|
|
||||||
|
|
||||||
# 4. Check logs (if needed)
|
|
||||||
ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'
|
|
||||||
|
|
||||||
# 5. Build ISO (when needed)
|
|
||||||
cd image-recipe
|
|
||||||
./build-debian-iso.sh # Only when making a release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cleanup Instructions
|
|
||||||
|
|
||||||
### Quick Cleanup (Run This Now)
|
|
||||||
```bash
|
|
||||||
cd /Users/dorian/Projects/archy
|
|
||||||
./cleanup-mac.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This removes:
|
|
||||||
- Rust build cache
|
|
||||||
- ISO build artifacts
|
|
||||||
- Old ISO files
|
|
||||||
|
|
||||||
**Saves: ~13 GB**
|
|
||||||
|
|
||||||
### Complete Cleanup (Optional)
|
|
||||||
|
|
||||||
1. **Uninstall Docker Desktop** (53 GB)
|
|
||||||
```bash
|
|
||||||
# Option 1: Using the app
|
|
||||||
# Open Docker Desktop → Troubleshoot → Uninstall
|
|
||||||
|
|
||||||
# Option 2: Manual removal
|
|
||||||
rm -rf ~/Library/Containers/com.docker.docker
|
|
||||||
rm -rf ~/Library/Application\ Support/Docker\ Desktop
|
|
||||||
rm -rf ~/.docker
|
|
||||||
brew uninstall --cask docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Remove Podman** (if not used)
|
|
||||||
```bash
|
|
||||||
brew uninstall podman
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Remove ISO build tools** (if not needed)
|
|
||||||
```bash
|
|
||||||
brew uninstall xorriso p7zip
|
|
||||||
```
|
|
||||||
|
|
||||||
**Total savings: ~66 GB**
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: Will this break my development workflow?**
|
|
||||||
A: No! You'll still be able to edit code and deploy. Build artifacts regenerate automatically.
|
|
||||||
|
|
||||||
**Q: What if I need to build locally?**
|
|
||||||
A: The tools (Rust, Node) remain installed. Only the cached artifacts are removed.
|
|
||||||
|
|
||||||
**Q: What about Docker containers?**
|
|
||||||
A: All containers run on the remote server (192.168.1.228), not locally.
|
|
||||||
|
|
||||||
**Q: Can I rebuild ISOs after cleanup?**
|
|
||||||
A: Yes! Just run `./build-debian-iso.sh` - it will recreate the build artifacts.
|
|
||||||
|
|
||||||
**Q: What if I delete too much?**
|
|
||||||
A: The cleanup script is conservative. Everything removed can be regenerated.
|
|
||||||
|
|
||||||
## After Cleanup
|
|
||||||
|
|
||||||
Your Mac will have:
|
|
||||||
- ✅ 66+ GB free disk space
|
|
||||||
- ✅ Fast, lean development environment
|
|
||||||
- ✅ All source code intact
|
|
||||||
- ✅ Full development capabilities
|
|
||||||
- ✅ Latest ISO ready to flash
|
|
||||||
|
|
||||||
Your workflow remains the same:
|
|
||||||
```
|
|
||||||
Edit → Deploy → Test (on remote) → Commit
|
|
||||||
```
|
|
||||||
105
README.md
105
README.md
@@ -2,39 +2,60 @@
|
|||||||
|
|
||||||
> Self-Sovereign Bitcoin Node OS
|
> Self-Sovereign Bitcoin Node OS
|
||||||
|
|
||||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and Web5 identity through a modern web interface.
|
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
|
||||||
|
|
||||||
[](https://www.debian.org/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Bitcoin Infrastructure
|
### Bitcoin Infrastructure
|
||||||
- **Bitcoin Knots** full node with pruning support
|
- **Bitcoin Knots** full node with pruning support
|
||||||
- **LND** Lightning Network daemon with channel management
|
- **LND** Lightning Network daemon with channel management
|
||||||
- **Electrs** Electrum server for wallet connectivity
|
- **ElectrumX** Electrum server for wallet connectivity
|
||||||
- **BTCPay Server** for accepting Bitcoin payments
|
- **BTCPay Server** for accepting Bitcoin payments
|
||||||
- **Mempool** block explorer and fee estimator
|
- **Mempool** block explorer and fee estimator
|
||||||
- **Fedimint** federation guardian and gateway
|
- **Fedimint** federation guardian and gateway
|
||||||
|
|
||||||
### Self-Hosted Apps (20+)
|
### Self-Hosted Apps (30)
|
||||||
Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), and more.
|
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||||
|
|
||||||
### Web5 Identity
|
### Decentralized Identity
|
||||||
- DID-based digital identity (Ed25519 + secp256k1)
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
- Verifiable Credentials issuance and verification
|
- Multi-identity management (Personal/Business/Anonymous)
|
||||||
- Decentralized Web Node (DWN) for data sync
|
- W3C Verifiable Credentials issuance and verification
|
||||||
- Nostr relay integration for node discovery
|
- Decentralized Web Node (DWN) with bidirectional sync over Tor
|
||||||
|
- Nostr relay integration and NIP-07 signing for iframe apps
|
||||||
|
|
||||||
|
### Multi-Node Federation
|
||||||
|
- Invite-based node joining over Tor hidden services
|
||||||
|
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||||
|
- Bidirectional DWN state sync between federated nodes
|
||||||
|
- File sharing with access controls (free/peers-only/paid)
|
||||||
|
|
||||||
|
### Mesh Networking
|
||||||
|
- LoRa radio communication via Meshcore protocol
|
||||||
|
- Device discovery and mesh routing
|
||||||
|
- Off-grid Bitcoin balance checks (planned)
|
||||||
|
|
||||||
|
### System Updates
|
||||||
|
- OTA updates from self-hosted Gitea (git.tx1138.com) with SHA256 verification
|
||||||
|
- Three update modes: Manual, Daily Check, Auto Apply (3 AM window)
|
||||||
|
- Rollback support with automatic backup before applying
|
||||||
|
- Full UI for update management in Settings
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- AES-256-GCM encrypted secrets at rest
|
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2id password hashing
|
||||||
- Container isolation: read-only root, capability dropping, non-root user
|
- Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
|
||||||
- TOTP two-factor authentication
|
- TOTP two-factor authentication
|
||||||
- Per-endpoint rate limiting and input validation
|
- Per-endpoint rate limiting, CSRF protection, input validation
|
||||||
- AppArmor profiles for container confinement
|
- AppArmor profiles for container confinement
|
||||||
|
- Tor hidden services for all inter-node communication
|
||||||
|
- All crypto and container dependencies pinned to exact versions
|
||||||
|
- Full penetration test completed (33 findings, all remediated)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -59,26 +80,25 @@ Storage (File Browser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vau
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Rust stable toolchain
|
- macOS or Linux for frontend development
|
||||||
- Node.js 20+
|
- Linux dev server (Debian 12) for backend builds — **never build Rust on macOS for Linux**
|
||||||
- Linux dev server (Debian 12) for backend builds
|
- Node.js 20+, Rust stable toolchain
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start # Dev server on http://localhost:8100
|
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
||||||
npm run type-check # TypeScript validation
|
npm run type-check # TypeScript validation
|
||||||
npm test # Run 515+ tests
|
npm run build # Production build → web/dist/neode-ui/
|
||||||
npm run build # Production build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to Server
|
### Deploy to Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live # Deploy to dev server
|
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||||
./scripts/deploy-to-target.sh --both # Deploy to both servers
|
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build ISO
|
### Build ISO
|
||||||
@@ -86,40 +106,47 @@ npm run build # Production build
|
|||||||
```bash
|
```bash
|
||||||
ssh archipelago@<server>
|
ssh archipelago@<server>
|
||||||
cd ~/archy/image-recipe
|
cd ~/archy/image-recipe
|
||||||
sudo ./build-auto-installer-iso.sh # x86_64
|
sudo ./build-auto-installer-iso.sh
|
||||||
sudo ARCH=arm64 ./build-auto-installer-iso.sh # ARM64
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Debian 12 (Bookworm)
|
Debian 12 (Bookworm)
|
||||||
├── Podman (rootless containers)
|
├── Rootless Podman (30 containers, archy-net DNS)
|
||||||
├── Nginx (reverse proxy + security headers)
|
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||||
├── Rust Backend (JSON-RPC API on port 5678)
|
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||||
│ ├── core/archipelago/ — RPC endpoints, state, identity
|
│ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
|
||||||
│ ├── core/container/ — Podman client, manifests, health
|
│ ├── core/container/ — PodmanClient (REST API socket), manifests, health
|
||||||
│ └── core/security/ — AppArmor, secrets, image verification
|
│ ├── core/security/ — AppArmor, secrets, Cosign image verification
|
||||||
└── Vue 3 Frontend (Composition API + TypeScript + Pinia)
|
│ └── 6 more crates — models, helpers, js-engine, performance, etc.
|
||||||
|
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind)
|
||||||
|
└── System Tor (hidden services, SOCKS5 proxy)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
~49,000 lines of Rust | ~47,000 lines of TypeScript/Vue | 78 shell scripts | 30 container apps
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Architecture](docs/architecture.md) — System design
|
| Doc | Purpose |
|
||||||
- [Developer Guide](docs/developer-guide.md) — Contributing guide
|
|-----|---------|
|
||||||
- [App Developer Guide](docs/app-developer-guide.md) — Writing app manifests
|
| [Architecture](docs/architecture.md) | System design, codebase stats, data paths |
|
||||||
- [App Manifest Spec](docs/app-manifest-spec.md) — YAML manifest format
|
| [Architecture Review (HTML)](docs/architecture-review.html) | Interactive guide with diagrams and learning path |
|
||||||
- [User Guide](docs/user-guide.md) — End-user documentation
|
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
||||||
- [Release Notes](RELEASE-NOTES-v1.0.0.md) — v1.0.0 release notes
|
| [API Reference](docs/api-reference.md) | Complete RPC endpoint reference |
|
||||||
- [v1.1 Roadmap](docs/roadmap-v1.1.md) — Upcoming features
|
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
||||||
- [v2.0 Roadmap](docs/roadmap-v2.0.md) — Long-term vision
|
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||||
|
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||||
|
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||||
|
| [Security Audit](docs/security-code-audit-2026-03.md) | Penetration test findings |
|
||||||
|
| [Master Plan](docs/MASTER_PLAN.md) | Phased roadmap and task tracking |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`feature/description`)
|
2. Create a feature branch (`feature/description`)
|
||||||
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
|
||||||
4. Submit a pull request with tests
|
4. Submit a pull request
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# Archipelago v0.5.0-beta Release Notes
|
|
||||||
|
|
||||||
**Release Date**: March 2026
|
|
||||||
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This is the first public beta of Archipelago, a self-sovereign Bitcoin Node OS. Flash it to a USB, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface.
|
|
||||||
|
|
||||||
## What's Included
|
|
||||||
|
|
||||||
### Core System
|
|
||||||
- Rust backend with RPC API and WebSocket real-time updates
|
|
||||||
- Vue 3 frontend with glassmorphism UI design
|
|
||||||
- Automated Podman container management
|
|
||||||
- Nginx reverse proxy with HTTPS (self-signed cert)
|
|
||||||
- Tor hidden services for all apps
|
|
||||||
|
|
||||||
### App Store (16+ Apps)
|
|
||||||
- **Bitcoin Stack**: Bitcoin Knots, Electrs, LND, BTCPay Server, Mempool, Fedimint
|
|
||||||
- **Storage**: File Browser, Immich, PhotoPrism
|
|
||||||
- **Productivity**: Penpot, SearXNG
|
|
||||||
- **AI**: Ollama (local LLMs)
|
|
||||||
- **Network**: Nostr Relay, Nginx Proxy Manager, Tailscale, Home Assistant
|
|
||||||
- **Platform**: IndeedHub
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- AES-256-GCM encrypted secrets on disk
|
|
||||||
- Session management: 24h inactivity expiry, max 5 concurrent sessions
|
|
||||||
- TOTP two-factor authentication with backup codes
|
|
||||||
- Container hardening: read-only root, no new privileges, dropped capabilities
|
|
||||||
- Pinned container image versions (no `:latest` tags)
|
|
||||||
- Login rate limiting (5 attempts per 60 seconds per IP)
|
|
||||||
- Path traversal prevention (nginx + client-side)
|
|
||||||
- Cookie-based auth (no tokens in URLs)
|
|
||||||
|
|
||||||
### Identity & Web5
|
|
||||||
- Decentralized Identifier (DID) generation
|
|
||||||
- Identity backup/restore
|
|
||||||
- Nostr relay support
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Backend startup: ~100ms
|
|
||||||
- Frontend bundle: ~105 KB gzipped
|
|
||||||
- WebSocket heartbeat with 30s ping/pong
|
|
||||||
- Exponential backoff reconnection (max 30s)
|
|
||||||
- Real-time install progress via WebSocket
|
|
||||||
- Server-side 5-minute inactivity timeout for stale connections
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
1. **ARM64 ISO**: ARM64 builds may require manual testing — primary testing is on x86_64
|
|
||||||
2. **Bitcoin Initial Sync**: First blockchain sync takes 1-7 days depending on hardware
|
|
||||||
3. **Self-signed HTTPS**: Browser shows certificate warning on first visit (expected)
|
|
||||||
4. **Restore from Backup**: Not yet implemented in onboarding flow
|
|
||||||
5. **Connect to Existing Server**: Not yet implemented in onboarding flow
|
|
||||||
6. **Immich**: Stack installation may take 5+ minutes due to multiple container images
|
|
||||||
7. **Memory**: Running all apps simultaneously requires 16+ GB RAM
|
|
||||||
8. **Disk Space**: Full Bitcoin node + all apps requires 800+ GB
|
|
||||||
|
|
||||||
## Upgrade Path
|
|
||||||
|
|
||||||
This is a beta release. No upgrade path from beta to stable is guaranteed. Back up your data before installing.
|
|
||||||
|
|
||||||
## System Requirements
|
|
||||||
|
|
||||||
| Component | Minimum | Recommended |
|
|
||||||
|-----------|---------|-------------|
|
|
||||||
| CPU | 4 cores | 8+ cores |
|
|
||||||
| RAM | 16 GB | 32 GB |
|
|
||||||
| Storage | 500 GB SSD | 2 TB NVMe |
|
|
||||||
| Network | Ethernet | Gigabit Ethernet |
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
1. Download the ISO for your architecture
|
|
||||||
2. Flash to USB with balenaEtcher or `dd`
|
|
||||||
3. Boot from USB on target hardware
|
|
||||||
4. Auto-installer runs — follow on-screen prompts
|
|
||||||
5. After reboot, navigate to `http://<server-ip>` in your browser
|
|
||||||
6. Complete the onboarding wizard
|
|
||||||
7. Start installing apps from the App Store
|
|
||||||
|
|
||||||
See [User Guide](docs/user-guide.md) for detailed instructions.
|
|
||||||
@@ -1,151 +1,85 @@
|
|||||||
# Archipelago Apps - Development Guide
|
# Archipelago Apps — Development Guide
|
||||||
|
|
||||||
This directory contains all prepackaged containerized applications for Archipelago NodeOS.
|
|
||||||
|
|
||||||
## App Overview
|
## App Overview
|
||||||
|
|
||||||
### Bitcoin & Lightning
|
### Bitcoin & Lightning
|
||||||
- **bitcoin-core**: Full Bitcoin node (ports: 8332, 8333) - v24.0.0
|
| App | Ports | Version |
|
||||||
- **lnd**: Lightning Network Daemon (ports: 9735, 10009, 8080)
|
|-----|-------|---------|
|
||||||
- **core-lightning**: Core Lightning implementation (ports: 9736, 9835)
|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||||
- **lightning-stack**: Complete Lightning implementation (ports: 9737, 10010, 8087) - v0.12.0
|
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||||
- **btcpay-server**: Bitcoin payment processor (ports: 80, 443) - v1.12.0
|
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||||
- **mempool**: Blockchain explorer (port: 4080) - v2.5.0
|
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
||||||
- **fedimint**: Federated Bitcoin minting (ports: 8173, 8174) - v0.3.0
|
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||||
|
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||||
|
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||||
|
|
||||||
### Nostr Relays
|
### Nostr
|
||||||
- **nostr-rs-relay**: High-performance Rust relay (port: 8081)
|
| App | Ports | Version |
|
||||||
- **strfry**: Lightweight C++ relay (port: 8082)
|
|-----|-------|---------|
|
||||||
|
| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 |
|
||||||
|
| nostrudel | 8082 (HTTP) | v0.40.0 |
|
||||||
|
|
||||||
### Web5 & Decentralized Protocols
|
### Self-Hosted
|
||||||
- **web5-dwn**: Decentralized Web Node (port: 3000)
|
| App | Port | Version |
|
||||||
- **did-wallet**: Web5 DID Wallet (port: 8083)
|
|-----|------|---------|
|
||||||
|
| nextcloud | 8084 | v28 |
|
||||||
### Self-Hosted Services
|
| jellyfin | 8096 | v10.8.13 |
|
||||||
- **home-assistant**: Home automation (port: 8123) - v2024.1.0
|
| immich | 2283 | release |
|
||||||
- **grafana**: Monitoring and dashboards (port: 3001) - v10.2.0
|
| photoprism | 2342 | v240915 |
|
||||||
- **ollama**: Local AI models (port: 11434) - v0.1.0
|
| vaultwarden | 8222 | v1.30.0-alpine |
|
||||||
- **searxng**: Privacy search engine (port: 8888) - v2024.1.0
|
| homeassistant | 8123 | v2024.1 |
|
||||||
- **onlyoffice**: Office suite (port: 8088) - v7.5.0
|
| filebrowser | 8083 | v2.27.0 |
|
||||||
- **penpot**: Design platform (port: 8089) - v2.0.0
|
| searxng | 8888 | 2024.11.17 |
|
||||||
|
| ollama | 11434 | v0.5.4 |
|
||||||
### Custom Applications
|
| grafana | 3001 | v10.2.0 |
|
||||||
- **endurain**: Application platform (port: 8085) - v1.0.0
|
| portainer | 9000 | v2.19.4 |
|
||||||
- **morphos-server**: MorphOS server (port: 8086) - v1.0.0
|
| onlyoffice | 8088 | v7.5.1 |
|
||||||
|
| penpot | 8089 | v2.4 |
|
||||||
### Mesh Networking
|
|
||||||
- **router**: Mesh routing and network management (ports: 8084, 5353, 1900)
|
|
||||||
- **meshtastic**: LoRa mesh networking (ports: 4403, 1883)
|
|
||||||
|
|
||||||
## Port Assignments
|
|
||||||
|
|
||||||
All apps use unique base ports. In development mode, ports are offset by 10000 (configurable).
|
|
||||||
|
|
||||||
See [PORTS.md](./PORTS.md) for complete port mapping.
|
|
||||||
|
|
||||||
Key apps:
|
|
||||||
- **bitcoin-core**: 8332, 8333 → 18332, 18333
|
|
||||||
- **btcpay-server**: 80, 443 → 10080, 10443
|
|
||||||
- **home-assistant**: 8123 → 18123
|
|
||||||
- **grafana**: 3001 → 13001
|
|
||||||
- **mempool**: 4080 → 14080
|
|
||||||
- **ollama**: 11434 → 21434
|
|
||||||
- **lightning-stack**: 9737, 10010, 8087 → 19737, 20010, 18087
|
|
||||||
|
|
||||||
## Building Apps
|
## Building Apps
|
||||||
|
|
||||||
### Build All Apps
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh
|
cd apps
|
||||||
|
./build.sh # Build all custom apps
|
||||||
|
./build.sh <app-id> # Build specific app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Specific App
|
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh <app-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build for Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh <app-id> --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
|
|
||||||
Each app directory contains:
|
Each app directory contains:
|
||||||
|
- `manifest.yml` — Container configuration
|
||||||
- `manifest.yml` - App manifest defining container configuration
|
- `Dockerfile` — Image definition (custom apps only)
|
||||||
- `Dockerfile` - Container image definition
|
- `README.md` — App-specific docs (custom apps only)
|
||||||
- `README.md` - App-specific documentation (for custom apps)
|
- `src/` — Source code (custom apps only)
|
||||||
- Source code (for custom apps: router, did-wallet, web5-dwn)
|
|
||||||
|
|
||||||
## Custom Apps
|
|
||||||
|
|
||||||
The following apps have custom implementations:
|
|
||||||
|
|
||||||
1. **router** - TypeScript/Node.js mesh router
|
|
||||||
2. **did-wallet** - TypeScript/Node.js Web5 wallet
|
|
||||||
3. **web5-dwn** - TypeScript/Node.js DWN server
|
|
||||||
|
|
||||||
These apps can be developed locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/<app-id>
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Standard Apps
|
|
||||||
|
|
||||||
The following apps use official Docker images:
|
|
||||||
|
|
||||||
- bitcoin-core (bitcoin/bitcoin:26.0)
|
|
||||||
- lnd (lightninglabs/lnd:v0.18.0)
|
|
||||||
- core-lightning (elementsproject/lightningd:v23.08.2)
|
|
||||||
- btcpay-server (btcpayserver/btcpayserver:1.12.0)
|
|
||||||
- nostr-rs-relay (scsibug/nostr-rs-relay:latest)
|
|
||||||
- strfry (strfry/strfry:latest)
|
|
||||||
- meshtastic (meshtastic/meshtastic:latest)
|
|
||||||
|
|
||||||
## Running in Development
|
## Running in Development
|
||||||
|
|
||||||
### Using Archipelago Backend
|
The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC:
|
||||||
|
|
||||||
The Archipelago backend will automatically:
|
|
||||||
1. Build local images if they don't exist
|
|
||||||
2. Apply port offsets in dev mode
|
|
||||||
3. Map volumes to `/tmp/archipelago-dev/<app-id>`
|
|
||||||
4. Start containers with proper networking
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
You can test apps manually:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the app
|
curl -X POST http://localhost:5959/rpc/v1 \
|
||||||
./build.sh <app-id>
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}'
|
||||||
# Run with Docker/Podman
|
|
||||||
docker run -p <host-port>:<container-port> \
|
|
||||||
-v /tmp/archipelago-dev/<app-id>:/data \
|
|
||||||
archipelago/<app-id>:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration with Archipelago
|
### Manual Testing (Podman)
|
||||||
|
|
||||||
Apps are integrated via:
|
```bash
|
||||||
|
# Build
|
||||||
|
./build.sh router
|
||||||
|
|
||||||
1. **Manifest files** - Define app configuration
|
# Run directly with Podman
|
||||||
2. **Container runtime** - Podman/Docker for execution
|
podman run -p 18084:8080 \
|
||||||
3. **Port manager** - Handles port allocation and offsets
|
-v /tmp/archipelago-dev/router:/app/data \
|
||||||
4. **Dev orchestrator** - Manages containers in development
|
localhost/archipelago/router:latest
|
||||||
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Integration Checklist
|
||||||
|
|
||||||
When building the OS image, these apps will be:
|
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
|
||||||
1. Built into container images
|
|
||||||
2. Included in the OS image
|
## Port Assignments
|
||||||
3. Available for installation via the UI
|
|
||||||
4. Pre-configured with proper networking and security
|
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
|
||||||
|
|||||||
@@ -1,70 +1,46 @@
|
|||||||
# Archipelago App Manifests
|
# Archipelago App Manifests
|
||||||
|
|
||||||
This directory contains app manifest definitions for containerized applications in the Archipelago Bitcoin Node OS.
|
Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits).
|
||||||
|
|
||||||
## App Categories
|
## App Categories
|
||||||
|
|
||||||
### Bitcoin & Lightning
|
### Bitcoin & Lightning
|
||||||
- `bitcoin-core/` - Bitcoin Core full node (v24.0.0)
|
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||||
- `lnd/` - Lightning Network Daemon
|
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||||
- `core-lightning/` - Core Lightning (CLN)
|
- **btcpay-server** — Payment processor (v1.13.5)
|
||||||
- `lightning-stack/` - Complete Lightning implementation (v0.12.0)
|
- **thunderhub** — Lightning management UI (v0.13.31)
|
||||||
- `btcpay-server/` - BTCPay Server payment processor (v1.12.0)
|
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||||
- `mempool/` - Mempool blockchain explorer (v2.5.0)
|
- **electrumx** — Electrum server
|
||||||
- `fedimint/` - Federated Bitcoin minting (v0.3.0)
|
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||||
|
|
||||||
### Web5 & Decentralized Protocols
|
### Nostr
|
||||||
- `nostr-rs-relay/` - High-performance Nostr relay (Rust)
|
- **nostr-rs-relay** — High-performance Rust relay (v0.9.0)
|
||||||
- `strfry/` - Nostr relay (C++)
|
- **nostrudel** — Nostr web client (v0.40.0)
|
||||||
- `web5-dwn/` - Decentralized Web Node
|
|
||||||
- `did-wallet/` - Web5 wallet with DID support
|
### Web5 & Identity
|
||||||
|
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
||||||
|
- **did-wallet** — Web5 DID Wallet
|
||||||
|
|
||||||
### Self-Hosted Services
|
### Self-Hosted Services
|
||||||
- `home-assistant/` - Home automation (v2024.1.0)
|
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||||
- `grafana/` - Monitoring and dashboards (v10.2.0)
|
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
||||||
- `ollama/` - Local AI models (v0.1.0)
|
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||||
- `searxng/` - Privacy-respecting search engine (v2024.1.0)
|
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||||
- `onlyoffice/` - Office suite (v7.5.0)
|
|
||||||
- `penpot/` - Design platform (v2.0.0)
|
|
||||||
|
|
||||||
### Custom Applications
|
### Networking
|
||||||
- `endurain/` - Endurain application platform (v1.0.0)
|
- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1)
|
||||||
- `morphos-server/` - MorphOS server (v1.0.0)
|
|
||||||
|
|
||||||
### Mesh Networking & Routing
|
### Custom & External
|
||||||
- `meshtastic/` - Meshtastic LoRa mesh networking
|
- **indeedhub** — Bitcoin documentary streaming (custom build)
|
||||||
- `router/` - Mesh routing and local network management
|
- **router** — Mesh routing and network management
|
||||||
|
- **botfights**, **nwnn**, **484-kitchen**, **call-the-operator**, **arch-presentation**, **syntropy-institute**, **t-zero** — External web apps
|
||||||
|
|
||||||
## Manifest Format
|
## Manifest Format
|
||||||
|
|
||||||
Each app has a `manifest.yml` file defining:
|
Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec.
|
||||||
- Container image and version
|
|
||||||
- Resource requirements
|
|
||||||
- Dependencies
|
|
||||||
- Security policies
|
|
||||||
- Health checks
|
|
||||||
- Network configuration
|
|
||||||
|
|
||||||
See `docs/app-manifest-spec.md` for the complete specification.
|
## Quick Reference
|
||||||
|
|
||||||
## Quick Start
|
- [PORTS.md](./PORTS.md) — Complete port mapping
|
||||||
|
- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps
|
||||||
### Build All Apps
|
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Specific App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh <app-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for development guide and [QUICKSTART.md](./QUICKSTART.md) for quick start instructions.
|
|
||||||
|
|
||||||
## Port Assignments
|
|
||||||
|
|
||||||
See [PORTS.md](./PORTS.md) for complete port mapping. All apps use unique ports and are automatically offset in development mode.
|
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Archipelago Complete ISO Build System
|
|
||||||
# =====================================
|
|
||||||
#
|
|
||||||
# This script builds a complete, flashable ISO from source with one command.
|
|
||||||
# It handles backend compilation, frontend bundling, and ISO creation.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./build-iso-complete.sh [options]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --local Build everything locally (requires Rust + Node.js)
|
|
||||||
# --remote HOST Build on remote server (recommended for ARM -> x86 cross-compile)
|
|
||||||
# --skip-backend Skip backend compilation (use existing binary)
|
|
||||||
# --skip-frontend Skip frontend build (use existing dist)
|
|
||||||
# --clean Clean all build artifacts before building
|
|
||||||
# --help Show this help message
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# ./build-iso-complete.sh --remote archipelago@192.168.1.228
|
|
||||||
# ./build-iso-complete.sh --local --clean
|
|
||||||
#
|
|
||||||
# Auto-installer from live server: when using --remote HOST, the ISO script
|
|
||||||
# (build-auto-installer-iso.sh) is run with DEV_SERVER=HOST so it captures
|
|
||||||
# backend, frontend, and container images from that host. Alternatively run
|
|
||||||
# DEV_SERVER=archipelago@192.168.1.228 ./image-recipe/build-auto-installer-iso.sh
|
|
||||||
# to build the auto-installer ISO with live capture only (no backend/frontend build).
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e # Exit on error
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Configuration
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
BUILD_DIR="$SCRIPT_DIR/image-recipe/build"
|
|
||||||
BACKEND_SRC="$SCRIPT_DIR/core/archipelago"
|
|
||||||
FRONTEND_SRC="$SCRIPT_DIR/neode-ui"
|
|
||||||
ISO_SCRIPT="$SCRIPT_DIR/image-recipe/build-auto-installer-iso.sh"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Build options (defaults)
|
|
||||||
BUILD_MODE="remote"
|
|
||||||
REMOTE_HOST=""
|
|
||||||
SKIP_BACKEND=false
|
|
||||||
SKIP_FRONTEND=false
|
|
||||||
CLEAN_BUILD=false
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helper Functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
print_header() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${BLUE}║${NC} $1"
|
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
print_success() {
|
|
||||||
echo -e "${GREEN}✅ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_error() {
|
|
||||||
echo -e "${RED}❌ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_warning() {
|
|
||||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
print_info() {
|
|
||||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
show_help() {
|
|
||||||
head -n 25 "$0" | grep "^#" | sed 's/^# //' | sed 's/^#//'
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
check_command() {
|
|
||||||
if ! command -v "$1" &> /dev/null; then
|
|
||||||
print_error "$1 is not installed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Parse Arguments
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--local)
|
|
||||||
BUILD_MODE="local"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--remote)
|
|
||||||
BUILD_MODE="remote"
|
|
||||||
REMOTE_HOST="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--skip-backend)
|
|
||||||
SKIP_BACKEND=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--skip-frontend)
|
|
||||||
SKIP_FRONTEND=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--clean)
|
|
||||||
CLEAN_BUILD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
show_help
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
print_error "Unknown option: $1"
|
|
||||||
show_help
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Pre-flight Checks
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
print_header "Archipelago Complete ISO Builder"
|
|
||||||
|
|
||||||
print_info "Build mode: $BUILD_MODE"
|
|
||||||
[[ -n "$REMOTE_HOST" ]] && print_info "Remote host: $REMOTE_HOST"
|
|
||||||
[[ "$SKIP_BACKEND" = true ]] && print_warning "Skipping backend build"
|
|
||||||
[[ "$SKIP_FRONTEND" = true ]] && print_warning "Skipping frontend build"
|
|
||||||
[[ "$CLEAN_BUILD" = true ]] && print_warning "Clean build enabled"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check for required commands
|
|
||||||
if [[ "$BUILD_MODE" == "remote" ]] && [[ -z "$REMOTE_HOST" ]]; then
|
|
||||||
print_error "Remote build mode requires --remote HOST"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$BUILD_MODE" == "remote" ]]; then
|
|
||||||
if ! check_command ssh; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! check_command rsync; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Step 1: Clean Build Artifacts (if requested)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if [[ "$CLEAN_BUILD" = true ]]; then
|
|
||||||
print_header "Cleaning Build Artifacts"
|
|
||||||
|
|
||||||
if [[ "$SKIP_BACKEND" = false ]]; then
|
|
||||||
print_info "Cleaning backend..."
|
|
||||||
rm -rf "$BACKEND_SRC/target"
|
|
||||||
print_success "Backend cleaned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_FRONTEND" = false ]]; then
|
|
||||||
print_info "Cleaning frontend..."
|
|
||||||
rm -rf "$FRONTEND_SRC/dist"
|
|
||||||
rm -rf "$FRONTEND_SRC/node_modules/.vite"
|
|
||||||
print_success "Frontend cleaned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_info "Cleaning ISO build directory..."
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
rm -rf "$SCRIPT_DIR/image-recipe/iso-workdir"
|
|
||||||
rm -rf "$SCRIPT_DIR/image-recipe/results"
|
|
||||||
print_success "ISO build artifacts cleaned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Step 2: Build Backend
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if [[ "$SKIP_BACKEND" = false ]]; then
|
|
||||||
print_header "Building Backend (Rust)"
|
|
||||||
|
|
||||||
if [[ "$BUILD_MODE" == "local" ]]; then
|
|
||||||
print_info "Building backend locally..."
|
|
||||||
cd "$BACKEND_SRC"
|
|
||||||
|
|
||||||
if ! check_command cargo; then
|
|
||||||
print_error "Rust/Cargo not installed. Install from: https://rustup.rs"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Copy to build directory
|
|
||||||
mkdir -p "$BUILD_DIR/backend"
|
|
||||||
cp target/release/archipelago "$BUILD_DIR/backend/"
|
|
||||||
chmod +x "$BUILD_DIR/backend/archipelago"
|
|
||||||
|
|
||||||
print_success "Backend built locally"
|
|
||||||
|
|
||||||
elif [[ "$BUILD_MODE" == "remote" ]]; then
|
|
||||||
print_info "Building backend on remote server: $REMOTE_HOST"
|
|
||||||
|
|
||||||
# Sync source code to remote
|
|
||||||
print_info "Syncing source code to remote..."
|
|
||||||
ssh "$REMOTE_HOST" "mkdir -p ~/archy-build"
|
|
||||||
rsync -az --delete \
|
|
||||||
--exclude 'target/' \
|
|
||||||
--exclude 'node_modules/' \
|
|
||||||
--exclude '.git/' \
|
|
||||||
"$BACKEND_SRC/" "$REMOTE_HOST:~/archy-build/core/archipelago/"
|
|
||||||
|
|
||||||
# Build on remote
|
|
||||||
print_info "Compiling backend on remote..."
|
|
||||||
ssh "$REMOTE_HOST" "cd ~/archy-build/core/archipelago && cargo build --release"
|
|
||||||
|
|
||||||
# Copy binary back
|
|
||||||
mkdir -p "$BUILD_DIR/backend"
|
|
||||||
print_info "Copying binary back to local..."
|
|
||||||
scp "$REMOTE_HOST:~/archy-build/core/archipelago/target/release/archipelago" "$BUILD_DIR/backend/"
|
|
||||||
chmod +x "$BUILD_DIR/backend/archipelago"
|
|
||||||
|
|
||||||
print_success "Backend built on remote server"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify binary
|
|
||||||
if [[ ! -f "$BUILD_DIR/backend/archipelago" ]]; then
|
|
||||||
print_error "Backend binary not found after build!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BINARY_SIZE=$(du -h "$BUILD_DIR/backend/archipelago" | awk '{print $1}')
|
|
||||||
print_success "Backend binary ready ($BINARY_SIZE)"
|
|
||||||
else
|
|
||||||
print_warning "Skipping backend build (using existing binary)"
|
|
||||||
if [[ ! -f "$BUILD_DIR/backend/archipelago" ]]; then
|
|
||||||
print_error "No existing backend binary found at $BUILD_DIR/backend/archipelago"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Step 3: Build Frontend
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if [[ "$SKIP_FRONTEND" = false ]]; then
|
|
||||||
print_header "Building Frontend (Vue.js)"
|
|
||||||
|
|
||||||
cd "$FRONTEND_SRC"
|
|
||||||
|
|
||||||
if ! check_command npm; then
|
|
||||||
print_error "Node.js/npm not installed. Install from: https://nodejs.org"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install dependencies if needed
|
|
||||||
if [[ ! -d "node_modules" ]] || [[ "$CLEAN_BUILD" = true ]]; then
|
|
||||||
print_info "Installing dependencies..."
|
|
||||||
npm install
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
print_info "Building frontend..."
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Copy to build directory
|
|
||||||
mkdir -p "$BUILD_DIR/frontend"
|
|
||||||
cp -r dist/* "$BUILD_DIR/frontend/"
|
|
||||||
|
|
||||||
print_success "Frontend built"
|
|
||||||
|
|
||||||
# Verify dist
|
|
||||||
if [[ ! -d "$BUILD_DIR/frontend" ]] || [[ -z "$(ls -A "$BUILD_DIR/frontend")" ]]; then
|
|
||||||
print_error "Frontend build directory is empty!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DIST_SIZE=$(du -sh "$BUILD_DIR/frontend" | awk '{print $1}')
|
|
||||||
print_success "Frontend assets ready ($DIST_SIZE)"
|
|
||||||
else
|
|
||||||
print_warning "Skipping frontend build (using existing dist)"
|
|
||||||
if [[ ! -d "$BUILD_DIR/frontend" ]] || [[ -z "$(ls -A "$BUILD_DIR/frontend")" ]]; then
|
|
||||||
print_error "No existing frontend build found at $BUILD_DIR/frontend"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Step 4: Build ISO
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
print_header "Building Bootable ISO"
|
|
||||||
|
|
||||||
# Check if running on remote or need to transfer
|
|
||||||
if [[ "$BUILD_MODE" == "remote" ]]; then
|
|
||||||
print_info "Transferring build artifacts to remote server..."
|
|
||||||
|
|
||||||
# Sync entire project to remote
|
|
||||||
ssh "$REMOTE_HOST" "mkdir -p ~/archy"
|
|
||||||
rsync -az --delete \
|
|
||||||
--exclude '.git/' \
|
|
||||||
--exclude 'node_modules/' \
|
|
||||||
--exclude 'core/target/' \
|
|
||||||
--exclude 'core/parmanode/' \
|
|
||||||
"$SCRIPT_DIR/" "$REMOTE_HOST:~/archy/"
|
|
||||||
|
|
||||||
print_success "Files synced to remote"
|
|
||||||
|
|
||||||
print_info "Running ISO build on remote server (auto-installer, DEV_SERVER=$REMOTE_HOST)..."
|
|
||||||
ssh -t "$REMOTE_HOST" "cd ~/archy/image-recipe && DEV_SERVER=$REMOTE_HOST sudo -E bash build-auto-installer-iso.sh" || {
|
|
||||||
print_error "ISO build failed on remote server"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
print_success "ISO built on remote server"
|
|
||||||
|
|
||||||
# Copy ISO back to local
|
|
||||||
ISO_NAME="archipelago-installer-x86_64.iso"
|
|
||||||
print_info "Copying ISO back to local machine..."
|
|
||||||
mkdir -p "$SCRIPT_DIR/image-recipe/results"
|
|
||||||
scp "$REMOTE_HOST:~/archy/image-recipe/results/$ISO_NAME" "$SCRIPT_DIR/image-recipe/results/"
|
|
||||||
|
|
||||||
ISO_PATH="$SCRIPT_DIR/image-recipe/results/$ISO_NAME"
|
|
||||||
|
|
||||||
else
|
|
||||||
# Local build
|
|
||||||
print_info "Running ISO build locally (auto-installer)..."
|
|
||||||
cd "$SCRIPT_DIR/image-recipe"
|
|
||||||
sudo bash build-auto-installer-iso.sh
|
|
||||||
|
|
||||||
ISO_PATH="$SCRIPT_DIR/image-recipe/results/archipelago-installer-x86_64.iso"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Step 5: Verify and Report
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
print_header "Build Complete!"
|
|
||||||
|
|
||||||
if [[ -f "$ISO_PATH" ]]; then
|
|
||||||
ISO_SIZE=$(du -h "$ISO_PATH" | awk '{print $1}')
|
|
||||||
ISO_MD5=$(md5 -q "$ISO_PATH" 2>/dev/null || md5sum "$ISO_PATH" | awk '{print $1}')
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}✅ ISO ready for flashing!${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e " 📀 ${BLUE}ISO:${NC} $ISO_PATH"
|
|
||||||
echo -e " 📏 ${BLUE}Size:${NC} $ISO_SIZE"
|
|
||||||
echo -e " 🔐 ${BLUE}MD5:${NC} $ISO_MD5"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Next steps:${NC}"
|
|
||||||
echo ""
|
|
||||||
echo " 1. Insert USB drive"
|
|
||||||
echo " 2. Find device: ${BLUE}diskutil list${NC}"
|
|
||||||
echo " 3. Flash ISO:"
|
|
||||||
echo ""
|
|
||||||
echo " ${BLUE}cd image-recipe && ./write-usb-dd.sh /dev/diskN${NC}"
|
|
||||||
echo ""
|
|
||||||
echo " 4. Boot from USB on target device"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create a flash script for convenience
|
|
||||||
cat > "$SCRIPT_DIR/flash-to-usb.sh" <<'FLASH_EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
# Quick USB flash script
|
|
||||||
cd "$(dirname "$0")/image-recipe" && ./write-usb-dd.sh "$@"
|
|
||||||
FLASH_EOF
|
|
||||||
chmod +x "$SCRIPT_DIR/flash-to-usb.sh"
|
|
||||||
|
|
||||||
print_success "Created convenience script: ./flash-to-usb.sh"
|
|
||||||
|
|
||||||
else
|
|
||||||
print_error "ISO not found at expected location: $ISO_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Done!
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${GREEN}║ 🎉 Build Complete - Ready to Flash! ║${NC}"
|
|
||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Archipelago Production macOS Build Script
|
|
||||||
# Creates a production-ready .app bundle and .dmg installer
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
APP_NAME="Archipelago"
|
|
||||||
APP_VERSION="${ARCHIPELAGO_VERSION:-0.1.0}"
|
|
||||||
BUILD_DIR="$PROJECT_ROOT/build/macos"
|
|
||||||
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
|
|
||||||
DMG_NAME="Archipelago-${APP_VERSION}-macOS.dmg"
|
|
||||||
|
|
||||||
echo "🏗️ Archipelago macOS Production Build"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " Version: $APP_VERSION"
|
|
||||||
echo " Target: macOS App Bundle + DMG Installer"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Clean previous build
|
|
||||||
echo "🧹 Cleaning previous build..."
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
mkdir -p "$BUILD_DIR"
|
|
||||||
|
|
||||||
# Step 1: Build Rust Backend (Release Mode)
|
|
||||||
echo ""
|
|
||||||
echo "⚙️ Step 1/6: Building Rust backend (release mode)..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
cd "$PROJECT_ROOT/core"
|
|
||||||
cargo build --release --workspace
|
|
||||||
|
|
||||||
if [ ! -f "target/release/archipelago" ]; then
|
|
||||||
echo "❌ Backend build failed - archipelago binary not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get binary size
|
|
||||||
BACKEND_SIZE=$(du -h target/release/archipelago | cut -f1)
|
|
||||||
echo "✅ Backend built successfully ($BACKEND_SIZE)"
|
|
||||||
|
|
||||||
# Step 2: Build Vue.js Frontend (Production Mode)
|
|
||||||
echo ""
|
|
||||||
echo "🎨 Step 2/6: Building Vue.js frontend (production mode)..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
cd "$PROJECT_ROOT/neode-ui"
|
|
||||||
|
|
||||||
# Check if node_modules exists
|
|
||||||
if [ ! -d "node_modules" ]; then
|
|
||||||
echo "📦 Installing frontend dependencies..."
|
|
||||||
npm install
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build production frontend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
if [ ! -d "dist" ]; then
|
|
||||||
echo "❌ Frontend build failed - dist directory not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get build size
|
|
||||||
FRONTEND_SIZE=$(du -sh dist | cut -f1)
|
|
||||||
echo "✅ Frontend built successfully ($FRONTEND_SIZE)"
|
|
||||||
|
|
||||||
# Step 3: Create macOS App Bundle Structure
|
|
||||||
echo ""
|
|
||||||
echo "📦 Step 3/6: Creating macOS app bundle..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
# Create standard macOS .app directory structure
|
|
||||||
mkdir -p "$APP_BUNDLE/Contents/MacOS"
|
|
||||||
mkdir -p "$APP_BUNDLE/Contents/Resources"
|
|
||||||
mkdir -p "$APP_BUNDLE/Contents/Frameworks"
|
|
||||||
|
|
||||||
# Copy backend binary
|
|
||||||
echo " • Copying backend binary..."
|
|
||||||
cp "$PROJECT_ROOT/core/target/release/archipelago" "$APP_BUNDLE/Contents/MacOS/"
|
|
||||||
chmod +x "$APP_BUNDLE/Contents/MacOS/archipelago"
|
|
||||||
|
|
||||||
# Copy frontend build
|
|
||||||
echo " • Copying frontend assets..."
|
|
||||||
cp -R "$PROJECT_ROOT/neode-ui/dist" "$APP_BUNDLE/Contents/Resources/frontend"
|
|
||||||
|
|
||||||
# Copy Docker UI assets
|
|
||||||
echo " • Copying Docker UI assets..."
|
|
||||||
mkdir -p "$APP_BUNDLE/Contents/Resources/docker-ui"
|
|
||||||
cp -R "$PROJECT_ROOT/docker/bitcoin-ui" "$APP_BUNDLE/Contents/Resources/docker-ui/"
|
|
||||||
cp -R "$PROJECT_ROOT/docker/lnd-ui" "$APP_BUNDLE/Contents/Resources/docker-ui/"
|
|
||||||
|
|
||||||
# Copy configuration templates
|
|
||||||
echo " • Copying configuration..."
|
|
||||||
cp "$PROJECT_ROOT/core/.env.example" "$APP_BUNDLE/Contents/Resources/env.template"
|
|
||||||
cp "$PROJECT_ROOT/core/.env.production" "$APP_BUNDLE/Contents/Resources/env.production"
|
|
||||||
|
|
||||||
# Copy docker-compose.yml for production
|
|
||||||
echo " • Copying Docker configuration..."
|
|
||||||
cp "$PROJECT_ROOT/docker-compose.yml" "$APP_BUNDLE/Contents/Resources/"
|
|
||||||
cp "$PROJECT_ROOT/manage-docker.sh" "$APP_BUNDLE/Contents/MacOS/"
|
|
||||||
chmod +x "$APP_BUNDLE/Contents/MacOS/manage-docker.sh"
|
|
||||||
|
|
||||||
# Create launch script
|
|
||||||
echo " • Creating launcher script..."
|
|
||||||
cat > "$APP_BUNDLE/Contents/MacOS/launch.sh" << 'LAUNCH_EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
# Archipelago macOS Launcher
|
|
||||||
|
|
||||||
# Get the directory containing this script
|
|
||||||
BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
RESOURCES_DIR="$BUNDLE_DIR/Resources"
|
|
||||||
MACOS_DIR="$BUNDLE_DIR/MacOS"
|
|
||||||
|
|
||||||
# Set up data directory in user's home
|
|
||||||
DATA_DIR="$HOME/Library/Application Support/Archipelago"
|
|
||||||
mkdir -p "$DATA_DIR/data"
|
|
||||||
mkdir -p "$DATA_DIR/logs"
|
|
||||||
|
|
||||||
# Export environment variables
|
|
||||||
export ARCHIPELAGO_DATA_DIR="$DATA_DIR/data"
|
|
||||||
export ARCHIPELAGO_FRONTEND_DIR="$RESOURCES_DIR/frontend"
|
|
||||||
export ARCHIPELAGO_DOCKER_UI_DIR="$RESOURCES_DIR/docker-ui"
|
|
||||||
export ARCHIPELAGO_LOG_DIR="$DATA_DIR/logs"
|
|
||||||
export RUST_LOG="${RUST_LOG:-info}"
|
|
||||||
|
|
||||||
# Launch backend
|
|
||||||
cd "$DATA_DIR"
|
|
||||||
exec "$MACOS_DIR/archipelago" > "$DATA_DIR/logs/archipelago.log" 2>&1
|
|
||||||
LAUNCH_EOF
|
|
||||||
|
|
||||||
chmod +x "$APP_BUNDLE/Contents/MacOS/launch.sh"
|
|
||||||
|
|
||||||
# Step 4: Create Info.plist
|
|
||||||
echo ""
|
|
||||||
echo "📄 Step 4/6: Creating app metadata..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
cat > "$APP_BUNDLE/Contents/Info.plist" << PLIST_EOF
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>Archipelago</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Archipelago</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.archipelago.app</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$APP_VERSION</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$APP_VERSION</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleSignature</key>
|
|
||||||
<string>ARCH</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>launch.sh</string>
|
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
<key>NSHighResolutionCapable</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
|
||||||
<string>10.15</string>
|
|
||||||
<key>LSApplicationCategoryType</key>
|
|
||||||
<string>public.app-category.utilities</string>
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
|
||||||
<string>Copyright © 2026 Archipelago. All rights reserved.</string>
|
|
||||||
<key>LSBackgroundOnly</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSRequiresAquaSystemAppearance</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
PLIST_EOF
|
|
||||||
|
|
||||||
echo "✅ Info.plist created"
|
|
||||||
|
|
||||||
# Create PkgInfo
|
|
||||||
echo -n "APPLARCH" > "$APP_BUNDLE/Contents/PkgInfo"
|
|
||||||
|
|
||||||
# Step 5: Create App Icon (placeholder - user should provide real icon)
|
|
||||||
echo ""
|
|
||||||
echo "🎨 Step 5/6: Creating app icon..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
# Check if sips command is available (macOS built-in)
|
|
||||||
if command -v sips >/dev/null 2>&1; then
|
|
||||||
# Try to find a logo to convert
|
|
||||||
LOGO_SOURCE=""
|
|
||||||
if [ -f "$PROJECT_ROOT/neode-ui/public/assets/img/logo.png" ]; then
|
|
||||||
LOGO_SOURCE="$PROJECT_ROOT/neode-ui/public/assets/img/logo.png"
|
|
||||||
elif [ -f "$PROJECT_ROOT/neode-ui/public/assets/img/app-icons/bitcoin-core.png" ]; then
|
|
||||||
LOGO_SOURCE="$PROJECT_ROOT/neode-ui/public/assets/img/app-icons/bitcoin-core.png"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$LOGO_SOURCE" ]; then
|
|
||||||
# Create iconset
|
|
||||||
ICONSET_DIR="$BUILD_DIR/AppIcon.iconset"
|
|
||||||
mkdir -p "$ICONSET_DIR"
|
|
||||||
|
|
||||||
# Generate icon sizes
|
|
||||||
for size in 16 32 128 256 512; do
|
|
||||||
sips -z $size $size "$LOGO_SOURCE" --out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null 2>&1
|
|
||||||
sips -z $((size*2)) $((size*2)) "$LOGO_SOURCE" --out "$ICONSET_DIR/icon_${size}x${size}@2x.png" >/dev/null 2>&1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Convert to icns
|
|
||||||
iconutil -c icns "$ICONSET_DIR" -o "$APP_BUNDLE/Contents/Resources/AppIcon.icns" 2>/dev/null || {
|
|
||||||
echo "⚠️ Icon conversion failed - app will use default icon"
|
|
||||||
}
|
|
||||||
|
|
||||||
rm -rf "$ICONSET_DIR"
|
|
||||||
echo "✅ App icon created from $LOGO_SOURCE"
|
|
||||||
else
|
|
||||||
echo "⚠️ No logo found - app will use default icon"
|
|
||||||
echo " Add logo.png to neode-ui/public/assets/img/ and rebuild"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ sips not available - skipping icon creation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 6: Create DMG Installer
|
|
||||||
echo ""
|
|
||||||
echo "💿 Step 6/6: Creating DMG installer..."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
DMG_TEMP_DIR="$BUILD_DIR/dmg"
|
|
||||||
mkdir -p "$DMG_TEMP_DIR"
|
|
||||||
|
|
||||||
# Copy app to DMG staging
|
|
||||||
cp -R "$APP_BUNDLE" "$DMG_TEMP_DIR/"
|
|
||||||
|
|
||||||
# Create Applications symlink
|
|
||||||
ln -s /Applications "$DMG_TEMP_DIR/Applications"
|
|
||||||
|
|
||||||
# Create DMG
|
|
||||||
hdiutil create -volname "Archipelago $APP_VERSION" \
|
|
||||||
-srcfolder "$DMG_TEMP_DIR" \
|
|
||||||
-ov -format UDZO \
|
|
||||||
"$BUILD_DIR/$DMG_NAME" 2>/dev/null || {
|
|
||||||
echo "⚠️ DMG creation failed - using app bundle only"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
rm -rf "$DMG_TEMP_DIR"
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "✅ Production build complete!"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo ""
|
|
||||||
echo "📦 Build artifacts:"
|
|
||||||
echo " • App Bundle: $APP_BUNDLE"
|
|
||||||
if [ -f "$BUILD_DIR/$DMG_NAME" ]; then
|
|
||||||
DMG_SIZE=$(du -h "$BUILD_DIR/$DMG_NAME" | cut -f1)
|
|
||||||
echo " • DMG Installer: $BUILD_DIR/$DMG_NAME ($DMG_SIZE)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "📋 Build summary:"
|
|
||||||
echo " • Backend: $BACKEND_SIZE (Rust)"
|
|
||||||
echo " • Frontend: $FRONTEND_SIZE (Vue.js)"
|
|
||||||
BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
|
|
||||||
echo " • Total Bundle: $BUNDLE_SIZE"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Next steps:"
|
|
||||||
echo " 1. Test the app:"
|
|
||||||
echo " open \"$APP_BUNDLE\""
|
|
||||||
echo ""
|
|
||||||
echo " 2. Install system-wide:"
|
|
||||||
echo " cp -R \"$APP_BUNDLE\" /Applications/"
|
|
||||||
echo ""
|
|
||||||
echo " 3. Distribute via DMG:"
|
|
||||||
if [ -f "$BUILD_DIR/$DMG_NAME" ]; then
|
|
||||||
echo " • Share: $BUILD_DIR/$DMG_NAME"
|
|
||||||
else
|
|
||||||
echo " • (DMG creation skipped - use app bundle directly)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo " 4. Code signing (optional but recommended):"
|
|
||||||
echo " codesign --deep --force --verify --verbose \\
|
|
||||||
--sign \"Developer ID Application: Your Name\" \\
|
|
||||||
\"$APP_BUNDLE\""
|
|
||||||
echo ""
|
|
||||||
echo "💡 For notarization (macOS 10.14.5+):"
|
|
||||||
echo " • Requires Apple Developer account"
|
|
||||||
echo " • Use: xcrun notarytool submit $DMG_NAME ..."
|
|
||||||
echo ""
|
|
||||||
101
cleanup-mac.sh
101
cleanup-mac.sh
@@ -1,101 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Archipelago Mac Cleanup Script
|
|
||||||
# Removes unnecessary local development artifacts
|
|
||||||
# Safe to run - only removes build caches and Docker data
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ Archipelago Mac Cleanup ║"
|
|
||||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Track space saved
|
|
||||||
TOTAL_SAVED=0
|
|
||||||
|
|
||||||
# Function to calculate and display savings
|
|
||||||
calc_savings() {
|
|
||||||
local path="$1"
|
|
||||||
if [ -e "$path" ]; then
|
|
||||||
local size=$(du -sk "$path" | cut -f1)
|
|
||||||
TOTAL_SAVED=$((TOTAL_SAVED + size))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Clean Rust build artifacts (1.6 GB)
|
|
||||||
if [ -d "core/target" ]; then
|
|
||||||
echo "🧹 Cleaning Rust build artifacts..."
|
|
||||||
calc_savings "core/target"
|
|
||||||
rm -rf core/target
|
|
||||||
echo " ✅ Removed core/target/ (~1.6 GB)"
|
|
||||||
else
|
|
||||||
echo " ✅ core/target/ already clean"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Clean ISO build artifacts (8.6 GB)
|
|
||||||
if [ -d "image-recipe/build" ]; then
|
|
||||||
echo "🧹 Cleaning ISO build artifacts..."
|
|
||||||
calc_savings "image-recipe/build"
|
|
||||||
rm -rf image-recipe/build
|
|
||||||
echo " ✅ Removed image-recipe/build/ (~8.6 GB)"
|
|
||||||
else
|
|
||||||
echo " ✅ image-recipe/build/ already clean"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Clean old ISOs (keep latest only)
|
|
||||||
if [ -d "image-recipe/results" ]; then
|
|
||||||
ISO_COUNT=$(ls -1 image-recipe/results/*.iso 2>/dev/null | wc -l | tr -d ' ')
|
|
||||||
if [ "$ISO_COUNT" -gt 1 ]; then
|
|
||||||
echo "🧹 Cleaning old ISO files (keeping latest)..."
|
|
||||||
# Keep the most recent ISO, delete others
|
|
||||||
cd image-recipe/results
|
|
||||||
ls -t *.iso | tail -n +2 | while read iso; do
|
|
||||||
calc_savings "$iso"
|
|
||||||
echo " 🗑️ Removing $iso"
|
|
||||||
rm "$iso"
|
|
||||||
done
|
|
||||||
cd ../..
|
|
||||||
echo " ✅ Kept latest ISO, removed old ones (~3 GB saved)"
|
|
||||||
else
|
|
||||||
echo " ✅ Only one ISO found, keeping it"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Show Docker Desktop warning (requires manual removal)
|
|
||||||
DOCKER_SIZE=$(du -sk ~/Library/Containers/com.docker.docker 2>/dev/null | cut -f1 || echo "0")
|
|
||||||
if [ "$DOCKER_SIZE" -gt 1000000 ]; then
|
|
||||||
DOCKER_GB=$((DOCKER_SIZE / 1024 / 1024))
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ Docker Desktop Data Found: ~${DOCKER_GB} GB"
|
|
||||||
echo " Location: ~/Library/Containers/com.docker.docker"
|
|
||||||
echo ""
|
|
||||||
echo " Since you develop on the remote server, you likely don't need this."
|
|
||||||
echo " To remove Docker Desktop completely:"
|
|
||||||
echo " 1. Open Docker Desktop app"
|
|
||||||
echo " 2. Troubleshoot → Uninstall"
|
|
||||||
echo " OR manually: rm -rf ~/Library/Containers/com.docker.docker"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ Cleanup Complete! ║"
|
|
||||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
SAVED_GB=$((TOTAL_SAVED / 1024 / 1024))
|
|
||||||
echo "💾 Space saved: ~${SAVED_GB} GB"
|
|
||||||
echo ""
|
|
||||||
echo "Your Mac now has:"
|
|
||||||
echo " ✅ Source code (for editing)"
|
|
||||||
echo " ✅ Deployment scripts (for remote dev)"
|
|
||||||
echo " ✅ Latest ISO (for flashing)"
|
|
||||||
echo " ❌ No build artifacts (rebuild on remote or in CI)"
|
|
||||||
echo ""
|
|
||||||
echo "Development workflow:"
|
|
||||||
echo " 1. Edit code locally"
|
|
||||||
echo " 2. Deploy: ./scripts/deploy-to-target.sh --live"
|
|
||||||
echo " 3. Test on: http://192.168.1.228"
|
|
||||||
echo ""
|
|
||||||
echo "To rebuild ISO when needed:"
|
|
||||||
echo " cd image-recipe && ./build-debian-iso.sh"
|
|
||||||
echo ""
|
|
||||||
19
core/Cargo.lock
generated
19
core/Cargo.lock
generated
@@ -80,11 +80,10 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.1.0"
|
version = "1.2.0-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
"archipelago-parmanode",
|
|
||||||
"archipelago-performance",
|
"archipelago-performance",
|
||||||
"archipelago-security",
|
"archipelago-security",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -147,6 +146,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hyper 0.14.32",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -159,20 +159,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "archipelago-parmanode"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"archipelago-container",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_yaml",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago-performance"
|
name = "archipelago-performance"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -202,6 +188,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"archipelago",
|
"archipelago",
|
||||||
"container",
|
"container",
|
||||||
"parmanode",
|
|
||||||
"performance",
|
"performance",
|
||||||
"security",
|
"security",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,22 +34,22 @@ futures-util = "0.3"
|
|||||||
archipelago-container = { path = "../container" }
|
archipelago-container = { path = "../container" }
|
||||||
archipelago-security = { path = "../security" }
|
archipelago-security = { path = "../security" }
|
||||||
archipelago-performance = { path = "../performance" }
|
archipelago-performance = { path = "../performance" }
|
||||||
archipelago-parmanode = { path = "../parmanode" }
|
|
||||||
|
|
||||||
# Database (optional for now - can use SQLite or skip)
|
# Database (optional for now - can use SQLite or skip)
|
||||||
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10.9"
|
||||||
hmac = "0.12"
|
hmac = "0.12.1"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
|
||||||
# Node identity (Ed25519 + X25519 key agreement)
|
# Node identity (Ed25519 + X25519 key agreement)
|
||||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
|
||||||
curve25519-dalek = "4"
|
curve25519-dalek = "4.1.3"
|
||||||
rand = "0.8"
|
rand = "0.8.5"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bs58 = "0.5"
|
bs58 = "0.5"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
@@ -66,8 +66,8 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "soc
|
|||||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||||
|
|
||||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||||
argon2 = "0.5"
|
argon2 = "0.5.3"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10.1"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
|
|
||||||
# Full system backup (tar archive + gzip compression)
|
# Full system backup (tar archive + gzip compression)
|
||||||
@@ -78,7 +78,7 @@ flate2 = "1.0"
|
|||||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
data-encoding = "2.6"
|
data-encoding = "2.6"
|
||||||
zeroize = { version = "1.7", features = ["derive"] }
|
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||||
|
|
||||||
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity)
|
||||||
mainline = "2"
|
mainline = "2"
|
||||||
@@ -89,7 +89,7 @@ bytes = "1"
|
|||||||
serial2-tokio = "0.1"
|
serial2-tokio = "0.1"
|
||||||
|
|
||||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||||
hkdf = "0.12"
|
hkdf = "0.12.4"
|
||||||
|
|
||||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||||
ciborium = "0.2.2"
|
ciborium = "0.2.2"
|
||||||
|
|||||||
@@ -1,846 +0,0 @@
|
|||||||
use crate::api::rpc::RpcHandler;
|
|
||||||
use crate::content_server;
|
|
||||||
use crate::electrs_status;
|
|
||||||
use crate::monitoring::MetricsStore;
|
|
||||||
use crate::network::dwn_store::DwnStore;
|
|
||||||
use crate::node_message as node_msg;
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::session::{self, SessionStore};
|
|
||||||
use crate::state::StateManager;
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use hyper::{Method, Request, Response, StatusCode};
|
|
||||||
use hyper_ws_listener::WsStream;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
use std::time::Instant;
|
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
pub struct ApiHandler {
|
|
||||||
config: Config,
|
|
||||||
rpc_handler: Arc<RpcHandler>,
|
|
||||||
state_manager: Arc<StateManager>,
|
|
||||||
metrics_store: Arc<MetricsStore>,
|
|
||||||
session_store: SessionStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiHandler {
|
|
||||||
pub async fn new(
|
|
||||||
config: Config,
|
|
||||||
state_manager: Arc<StateManager>,
|
|
||||||
metrics_store: Arc<MetricsStore>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let session_store = SessionStore::new();
|
|
||||||
let rpc_handler = Arc::new(
|
|
||||||
RpcHandler::new(
|
|
||||||
config.clone(),
|
|
||||||
state_manager.clone(),
|
|
||||||
metrics_store.clone(),
|
|
||||||
session_store.clone(),
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
config,
|
|
||||||
rpc_handler,
|
|
||||||
state_manager,
|
|
||||||
metrics_store,
|
|
||||||
session_store,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access the RPC handler (for service initialization after construction).
|
|
||||||
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
|
||||||
&self.rpc_handler
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the request has a valid session cookie.
|
|
||||||
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
|
||||||
match session::extract_session_cookie(headers) {
|
|
||||||
Some(token) => self.session_store.validate(&token).await,
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a 401 Unauthorized JSON response.
|
|
||||||
fn unauthorized() -> Response<hyper::Body> {
|
|
||||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allowed CORS origins derived from the config host IP.
|
|
||||||
fn allowed_origins(&self) -> Vec<String> {
|
|
||||||
vec![
|
|
||||||
format!("http://{}", self.config.host_ip),
|
|
||||||
format!("https://{}", self.config.host_ip),
|
|
||||||
"http://localhost:8100".to_string(), // Vite dev server
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the Origin header against allowed origins.
|
|
||||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
|
||||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
|
||||||
let origin = headers
|
|
||||||
.get("origin")
|
|
||||||
.and_then(|v| v.to_str().ok())?;
|
|
||||||
let allowed = self.allowed_origins();
|
|
||||||
if allowed.iter().any(|a| a == origin) {
|
|
||||||
Some(origin.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_request(
|
|
||||||
&self,
|
|
||||||
req: Request<hyper::Body>,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let path = req.uri().path().to_string();
|
|
||||||
let method = req.method().clone();
|
|
||||||
|
|
||||||
// Handle CORS preflight for all routes
|
|
||||||
if method == Method::OPTIONS {
|
|
||||||
let mut builder = Response::builder()
|
|
||||||
.status(StatusCode::NO_CONTENT)
|
|
||||||
.header("Vary", "Origin");
|
|
||||||
if let Some(origin) = self.validate_origin(req.headers()) {
|
|
||||||
builder = builder
|
|
||||||
.header("Access-Control-Allow-Origin", &origin)
|
|
||||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
|
||||||
.header("Access-Control-Allow-Credentials", "true");
|
|
||||||
}
|
|
||||||
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket upgrade — validate session before upgrading
|
|
||||||
if method == Method::GET && path == "/ws/db" {
|
|
||||||
if !self.is_authenticated(req.headers()).await {
|
|
||||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
|
||||||
return Ok(Self::unauthorized());
|
|
||||||
}
|
|
||||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert body to bytes for non-WS routes
|
|
||||||
let headers = req.headers().clone();
|
|
||||||
let (parts, body) = req.into_parts();
|
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
|
||||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
|
||||||
|
|
||||||
debug!("{} {}", method, path);
|
|
||||||
|
|
||||||
match (method, path.as_str()) {
|
|
||||||
// RPC — auth is handled inside rpc handler per-method
|
|
||||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
|
||||||
|
|
||||||
// Health — unauthenticated
|
|
||||||
(Method::GET, "/health") => Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body(hyper::Body::from("OK"))
|
|
||||||
.unwrap()),
|
|
||||||
|
|
||||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
|
||||||
(Method::POST, "/archipelago/node-message") => {
|
|
||||||
Self::handle_node_message(body_bytes).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content serving — peers access shared content over Tor (no session auth)
|
|
||||||
(Method::GET, p) if p.starts_with("/content/") => {
|
|
||||||
Self::handle_content_request(p, &headers, &self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content catalog — list available content (no session auth, for peers)
|
|
||||||
(Method::GET, "/content") => {
|
|
||||||
Self::handle_content_catalog(&self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electrs status — unauthenticated (read-only sync status)
|
|
||||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
|
||||||
|
|
||||||
// LND connect info — unauthenticated (read-only, localhost only)
|
|
||||||
(Method::GET, "/lnd-connect-info") => {
|
|
||||||
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container logs — requires session
|
|
||||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
|
||||||
if !self.is_authenticated(&headers).await {
|
|
||||||
return Ok(Self::unauthorized());
|
|
||||||
}
|
|
||||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
|
||||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// LND proxy — requires session
|
|
||||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
|
||||||
if !self.is_authenticated(&headers).await {
|
|
||||||
return Ok(Self::unauthorized());
|
|
||||||
}
|
|
||||||
let origin = self.validate_origin(&headers).unwrap_or_default();
|
|
||||||
Self::handle_lnd_proxy(path, &origin).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// DWN health — unauthenticated
|
|
||||||
(Method::GET, "/dwn/health") => {
|
|
||||||
Self::handle_dwn_health(&self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
|
||||||
(Method::POST, "/dwn") => {
|
|
||||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Ok(Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(hyper::Body::from("Not Found"))
|
|
||||||
.unwrap()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_container_logs_http(
|
|
||||||
rpc: Arc<RpcHandler>,
|
|
||||||
path: &str,
|
|
||||||
cors_origin: &str,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let query = path
|
|
||||||
.strip_prefix("/api/container/logs")
|
|
||||||
.and_then(|s| s.strip_prefix('?'))
|
|
||||||
.unwrap_or("");
|
|
||||||
let params: std::collections::HashMap<String, String> =
|
|
||||||
query
|
|
||||||
.split('&')
|
|
||||||
.filter_map(|p| {
|
|
||||||
let mut it = p.splitn(2, '=');
|
|
||||||
let k = it.next()?.to_string();
|
|
||||||
let v = it.next()?.to_string();
|
|
||||||
Some((k, v))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
|
||||||
|
|
||||||
// Validate app_id format
|
|
||||||
if !is_valid_app_id(app_id) {
|
|
||||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines = params
|
|
||||||
.get("lines")
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.unwrap_or(200);
|
|
||||||
|
|
||||||
match rpc.get_container_logs_value(app_id, lines).await {
|
|
||||||
Ok(value) => {
|
|
||||||
let body = serde_json::json!({ "result": value });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Access-Control-Allow-Origin", cors_origin)
|
|
||||||
.header("Access-Control-Allow-Credentials", "true")
|
|
||||||
.header("Vary", "Origin")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let body = serde_json::json!({ "error": e.to_string() });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Access-Control-Allow-Origin", cors_origin)
|
|
||||||
.header("Access-Control-Allow-Credentials", "true")
|
|
||||||
.header("Vary", "Origin")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Incoming {
|
|
||||||
from_pubkey: Option<String>,
|
|
||||||
message: Option<String>,
|
|
||||||
}
|
|
||||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
|
||||||
from_pubkey: None,
|
|
||||||
message: None,
|
|
||||||
});
|
|
||||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
|
||||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
|
||||||
if !is_valid_pubkey_hex(&from) {
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
// Sanitize log output to prevent log injection
|
|
||||||
let safe_from = sanitize_log_string(&from);
|
|
||||||
let safe_msg = sanitize_log_string(&msg);
|
|
||||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
|
||||||
// Sanitize stored message content (strip HTML entities)
|
|
||||||
let clean_from = sanitize_html(&from);
|
|
||||||
let clean_msg = sanitize_html(&msg);
|
|
||||||
node_msg::store_received(&clean_from, &clean_msg).await;
|
|
||||||
}
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(r#"{"ok":true}"#))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
|
||||||
let status = electrs_status::get_electrs_sync_status().await;
|
|
||||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_lnd_connect_info(
|
|
||||||
rpc: std::sync::Arc<super::rpc::RpcHandler>,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
match rpc.handle_lnd_connect_info().await {
|
|
||||||
Ok(val) => {
|
|
||||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Err(e) => Ok(Response::builder()
|
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(
|
|
||||||
serde_json::json!({"error": e.to_string()}).to_string(),
|
|
||||||
))
|
|
||||||
.unwrap()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
|
||||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
|
||||||
match reqwest::get(&url).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
let headers = resp.headers().clone();
|
|
||||||
let body = resp.bytes().await.unwrap_or_default();
|
|
||||||
let mut builder = Response::builder().status(status);
|
|
||||||
if let Some(ct) = headers.get("content-type") {
|
|
||||||
if let Ok(s) = ct.to_str() {
|
|
||||||
builder = builder.header("Content-Type", s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder
|
|
||||||
.header("Access-Control-Allow-Origin", cors_origin)
|
|
||||||
.header("Access-Control-Allow-Credentials", "true")
|
|
||||||
.header("Vary", "Origin")
|
|
||||||
.body(hyper::Body::from(body))
|
|
||||||
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let body = serde_json::json!({ "error": e.to_string() });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::BAD_GATEWAY)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Access-Control-Allow-Origin", cors_origin)
|
|
||||||
.header("Access-Control-Allow-Credentials", "true")
|
|
||||||
.header("Vary", "Origin")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
|
||||||
match content_server::load_catalog(&config.data_dir).await {
|
|
||||||
Ok(catalog) => {
|
|
||||||
// Only expose public metadata for available items
|
|
||||||
let items: Vec<serde_json::Value> = catalog
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
|
||||||
.map(|i| {
|
|
||||||
serde_json::json!({
|
|
||||||
"id": i.id,
|
|
||||||
"filename": i.filename,
|
|
||||||
"mime_type": i.mime_type,
|
|
||||||
"size_bytes": i.size_bytes,
|
|
||||||
"description": i.description,
|
|
||||||
"access": i.access,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
|
||||||
.unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let body = serde_json::json!({ "error": e.to_string() });
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_content_request(
|
|
||||||
path: &str,
|
|
||||||
headers: &hyper::HeaderMap,
|
|
||||||
config: &Config,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
|
||||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(hyper::Body::from("Invalid content ID"))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract payment token from X-Payment-Token header
|
|
||||||
let payment_token = headers
|
|
||||||
.get("x-payment-token")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
// Extract federation peer DID from X-Federation-DID header
|
|
||||||
let peer_did = headers
|
|
||||||
.get("x-federation-did")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
// Parse Range header for streaming support
|
|
||||||
let range = headers
|
|
||||||
.get("range")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(content_server::parse_range_header);
|
|
||||||
|
|
||||||
match content_server::serve_content(
|
|
||||||
&config.data_dir,
|
|
||||||
content_id,
|
|
||||||
payment_token.as_deref(),
|
|
||||||
peer_did.as_deref(),
|
|
||||||
range,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
|
||||||
let len = bytes.len();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", mime_type)
|
|
||||||
.header("Content-Length", len.to_string())
|
|
||||||
.header("Accept-Ranges", "bytes")
|
|
||||||
.body(hyper::Body::from(bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Ok(content_server::ServeResult::Partial {
|
|
||||||
bytes,
|
|
||||||
mime_type,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
total,
|
|
||||||
}) => {
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::PARTIAL_CONTENT)
|
|
||||||
.header("Content-Type", mime_type)
|
|
||||||
.header("Content-Length", bytes.len().to_string())
|
|
||||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
|
||||||
.header("Accept-Ranges", "bytes")
|
|
||||||
.body(hyper::Body::from(bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"error": "Payment required",
|
|
||||||
"price_sats": price_sats,
|
|
||||||
"payment_header": "X-Payment-Token",
|
|
||||||
});
|
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::PAYMENT_REQUIRED)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body_bytes))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Ok(content_server::ServeResult::Forbidden) => {
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::FORBIDDEN)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(
|
|
||||||
r#"{"error":"Access denied — federation peer required"}"#,
|
|
||||||
))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(hyper::Body::from("Content not found"))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_websocket(
|
|
||||||
req: Request<hyper::Body>,
|
|
||||||
state_manager: Arc<StateManager>,
|
|
||||||
metrics_store: Arc<MetricsStore>,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
|
||||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
|
||||||
|
|
||||||
if let Some(ws_fut) = ws_fut_opt {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let ws_stream: WsStream = match ws_fut.await {
|
|
||||||
Ok(Ok(s)) => s,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
debug!("WebSocket handshake failed (hyper): {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("WebSocket task join failed: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
metrics_store.increment_ws();
|
|
||||||
info!("WebSocket /ws/db connected");
|
|
||||||
|
|
||||||
let (mut tx, mut rx) = ws_stream.split();
|
|
||||||
|
|
||||||
let initial_msg = state_manager.get_initial_message().await;
|
|
||||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
|
||||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
|
||||||
debug!("Failed to send initial data: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut state_rx = state_manager.subscribe();
|
|
||||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
|
||||||
tokio::pin!(ping_interval);
|
|
||||||
let mut last_client_activity = Instant::now();
|
|
||||||
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = ping_interval.tick() => {
|
|
||||||
// Check inactivity timeout
|
|
||||||
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
|
||||||
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
|
||||||
let _ = tx.send(Message::Close(None)).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if tx.send(Message::Ping(vec![])).await.is_err() {
|
|
||||||
debug!("Failed to send ping, connection likely closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update = state_rx.recv() => {
|
|
||||||
match update {
|
|
||||||
Ok(msg) => {
|
|
||||||
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
|
||||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
|
||||||
debug!("Failed to send state update: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
debug!("Sent state update at revision {}", msg.rev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
|
||||||
debug!("Client lagged behind, skipped {} messages", skipped);
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Closed) => {
|
|
||||||
debug!("Broadcast channel closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = rx.next() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(Message::Close(_))) => break,
|
|
||||||
Some(Ok(Message::Pong(_))) => {
|
|
||||||
last_client_activity = Instant::now();
|
|
||||||
debug!("Received pong");
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Ping(data))) => {
|
|
||||||
last_client_activity = Instant::now();
|
|
||||||
let _ = tx.send(Message::Pong(data)).await;
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Text(text))) => {
|
|
||||||
last_client_activity = Instant::now();
|
|
||||||
// Handle JSON ping from frontend
|
|
||||||
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
|
||||||
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => {
|
|
||||||
last_client_activity = Instant::now();
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
debug!("WebSocket stream error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metrics_store.decrement_ws();
|
|
||||||
info!("WebSocket /ws/db disconnected");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
|
||||||
fn is_valid_app_id(id: &str) -> bool {
|
|
||||||
!id.is_empty()
|
|
||||||
&& id.len() <= 64
|
|
||||||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
|
||||||
&& id.as_bytes()[0] != b'-'
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that a pubkey is a 64-char hex string.
|
|
||||||
fn is_valid_pubkey_hex(s: &str) -> bool {
|
|
||||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip newlines and ANSI escape sequences from strings before logging.
|
|
||||||
fn sanitize_log_string(s: &str) -> String {
|
|
||||||
s.replace('\n', "\\n")
|
|
||||||
.replace('\r', "\\r")
|
|
||||||
.replace('\x1b', "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
|
||||||
fn sanitize_html(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiHandler {
|
|
||||||
/// DWN health endpoint — returns store stats.
|
|
||||||
async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
|
||||||
match DwnStore::new(&config.data_dir).await {
|
|
||||||
Ok(store) => {
|
|
||||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
|
||||||
message_count: 0,
|
|
||||||
protocol_count: 0,
|
|
||||||
total_bytes: 0,
|
|
||||||
});
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"status": "ok",
|
|
||||||
"message_count": stats.message_count,
|
|
||||||
"protocol_count": stats.protocol_count,
|
|
||||||
"total_bytes": stats.total_bytes,
|
|
||||||
});
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(body.to_string()))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
Err(_) => Ok(Response::builder()
|
|
||||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(r#"{"status":"unavailable"}"#))
|
|
||||||
.unwrap()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
|
||||||
/// Supports batch processing: all messages in the array are processed.
|
|
||||||
async fn handle_dwn_message(
|
|
||||||
body: hyper::body::Bytes,
|
|
||||||
config: &Config,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(err.to_string()))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect all messages to process
|
|
||||||
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
|
||||||
vec![request["message"].clone()]
|
|
||||||
} else if let Some(msgs) = request["messages"].as_array() {
|
|
||||||
msgs.clone()
|
|
||||||
} else {
|
|
||||||
vec![serde_json::Value::Null]
|
|
||||||
};
|
|
||||||
|
|
||||||
let store = DwnStore::new(&config.data_dir).await?;
|
|
||||||
let mut results = Vec::new();
|
|
||||||
|
|
||||||
for message in &messages {
|
|
||||||
let interface = message["descriptor"]["interface"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
let method = message["descriptor"]["method"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let result = match (interface, method) {
|
|
||||||
("Records", "Write") => {
|
|
||||||
let author = message["author"].as_str().unwrap_or("unknown");
|
|
||||||
let protocol = message["descriptor"]["protocol"].as_str();
|
|
||||||
let schema = message["descriptor"]["schema"].as_str();
|
|
||||||
let data_format = message["descriptor"]["dataFormat"].as_str();
|
|
||||||
let data = message.get("data").cloned();
|
|
||||||
// Deduplicate: check if recordId already exists
|
|
||||||
if let Some(record_id) = message["recordId"].as_str() {
|
|
||||||
if store.read_message(record_id).await.ok().flatten().is_some() {
|
|
||||||
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
|
||||||
} else {
|
|
||||||
match store
|
|
||||||
.write_message(author, protocol, schema, data_format, data)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(msg) => {
|
|
||||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
|
||||||
}
|
|
||||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match store
|
|
||||||
.write_message(author, protocol, schema, data_format, data)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
|
||||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
("Records", "Query") => {
|
|
||||||
let query = crate::network::dwn_store::MessageQuery {
|
|
||||||
protocol: message["descriptor"]["filter"]["protocol"]
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
schema: message["descriptor"]["filter"]["schema"]
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
author: message["descriptor"]["filter"]["author"]
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
date_from: message["descriptor"]["filter"]["dateFrom"]
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
date_to: message["descriptor"]["filter"]["dateTo"]
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
limit: message["descriptor"]["filter"]["limit"]
|
|
||||||
.as_u64()
|
|
||||||
.map(|n| n as usize),
|
|
||||||
};
|
|
||||||
match store.query_messages(&query).await {
|
|
||||||
Ok(messages) => {
|
|
||||||
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
("Records", "Read") => {
|
|
||||||
let record_id = message["descriptor"]["recordId"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
match store.read_message(record_id).await {
|
|
||||||
Ok(Some(msg)) => {
|
|
||||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
|
||||||
}
|
|
||||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
|
||||||
Err(e) => {
|
|
||||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
("Records", "Delete") => {
|
|
||||||
let record_id = message["descriptor"]["recordId"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
match store.delete_message(record_id).await {
|
|
||||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
|
||||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
|
||||||
Err(e) => {
|
|
||||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return single result for single message, array for batch
|
|
||||||
let (response_body, http_status) = if results.len() == 1 {
|
|
||||||
let result = &results[0];
|
|
||||||
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
|
||||||
let http_status = match status_code {
|
|
||||||
202 => StatusCode::ACCEPTED,
|
|
||||||
400 => StatusCode::BAD_REQUEST,
|
|
||||||
404 => StatusCode::NOT_FOUND,
|
|
||||||
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
_ => StatusCode::OK,
|
|
||||||
};
|
|
||||||
(result.to_string(), http_status)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
serde_json::json!({"replies": results}).to_string(),
|
|
||||||
StatusCode::OK,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(http_status)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(hyper::Body::from(response_body))
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
core/archipelago/src/api/handler/content.rs
Normal file
122
core/archipelago/src/api/handler/content.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use super::build_response;use crate::content_server;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
|
use super::{ApiHandler, is_valid_app_id};
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||||
|
match content_server::load_catalog(&config.data_dir).await {
|
||||||
|
Ok(catalog) => {
|
||||||
|
// Only expose public metadata for available items
|
||||||
|
let items: Vec<serde_json::Value> = catalog
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||||
|
.map(|i| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": i.id,
|
||||||
|
"filename": i.filename,
|
||||||
|
"mime_type": i.mime_type,
|
||||||
|
"size_bytes": i.size_bytes,
|
||||||
|
"description": i.description,
|
||||||
|
"access": i.access,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let body = serde_json::json!({ "error": e.to_string() });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_content_request(
|
||||||
|
path: &str,
|
||||||
|
headers: &hyper::HeaderMap,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||||
|
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||||
|
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract payment token from X-Payment-Token header
|
||||||
|
let payment_token = headers
|
||||||
|
.get("x-payment-token")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Extract federation peer DID from X-Federation-DID header
|
||||||
|
let peer_did = headers
|
||||||
|
.get("x-federation-did")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Parse Range header for streaming support
|
||||||
|
let range = headers
|
||||||
|
.get("range")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(content_server::parse_range_header);
|
||||||
|
|
||||||
|
match content_server::serve_content(
|
||||||
|
&config.data_dir,
|
||||||
|
content_id,
|
||||||
|
payment_token.as_deref(),
|
||||||
|
peer_did.as_deref(),
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||||
|
let len = bytes.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", mime_type)
|
||||||
|
.header("Content-Length", len.to_string())
|
||||||
|
.header("Accept-Ranges", "bytes")
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
Ok(content_server::ServeResult::Partial {
|
||||||
|
bytes,
|
||||||
|
mime_type,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
total,
|
||||||
|
}) => {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
|
.header("Content-Type", mime_type)
|
||||||
|
.header("Content-Length", bytes.len().to_string())
|
||||||
|
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
||||||
|
.header("Accept-Ranges", "bytes")
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": "Payment required",
|
||||||
|
"price_sats": price_sats,
|
||||||
|
"payment_header": "X-Payment-Token",
|
||||||
|
});
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
|
||||||
|
}
|
||||||
|
Ok(content_server::ServeResult::Forbidden) => {
|
||||||
|
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
|
||||||
|
r#"{"error":"Access denied — federation peer required"}"#,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
||||||
|
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
core/archipelago/src/api/handler/dwn.rs
Normal file
189
core/archipelago/src/api/handler/dwn.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use super::build_response;use crate::network::dwn_store::DwnStore;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
|
use super::ApiHandler;
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
/// DWN health endpoint — returns store stats.
|
||||||
|
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||||
|
match DwnStore::new(&config.data_dir).await {
|
||||||
|
Ok(store) => {
|
||||||
|
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||||
|
message_count: 0,
|
||||||
|
protocol_count: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
});
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"message_count": stats.message_count,
|
||||||
|
"protocol_count": stats.protocol_count,
|
||||||
|
"total_bytes": stats.total_bytes,
|
||||||
|
});
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(body.to_string()))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete.
|
||||||
|
/// Supports batch processing: all messages in the array are processed.
|
||||||
|
pub(super) async fn handle_dwn_message(
|
||||||
|
body: hyper::body::Bytes,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
let request: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)});
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(err.to_string()))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all messages to process
|
||||||
|
let messages: Vec<serde_json::Value> = if request.get("message").is_some() {
|
||||||
|
vec![request["message"].clone()]
|
||||||
|
} else if let Some(msgs) = request["messages"].as_array() {
|
||||||
|
msgs.clone()
|
||||||
|
} else {
|
||||||
|
vec![serde_json::Value::Null]
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = DwnStore::new(&config.data_dir).await?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for message in &messages {
|
||||||
|
let interface = message["descriptor"]["interface"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
let method = message["descriptor"]["method"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let result = match (interface, method) {
|
||||||
|
("Records", "Write") => {
|
||||||
|
let author = message["author"].as_str().unwrap_or("unknown");
|
||||||
|
let protocol = message["descriptor"]["protocol"].as_str();
|
||||||
|
let schema = message["descriptor"]["schema"].as_str();
|
||||||
|
let data_format = message["descriptor"]["dataFormat"].as_str();
|
||||||
|
let data = message.get("data").cloned();
|
||||||
|
// Deduplicate: check if recordId already exists
|
||||||
|
if let Some(record_id) = message["recordId"].as_str() {
|
||||||
|
if store.read_message(record_id).await.ok().flatten().is_some() {
|
||||||
|
serde_json::json!({"status": {"code": 200, "detail": "Already exists"}})
|
||||||
|
} else {
|
||||||
|
match store
|
||||||
|
.write_message(author, protocol, schema, data_format, data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(msg) => {
|
||||||
|
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||||
|
}
|
||||||
|
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match store
|
||||||
|
.write_message(author, protocol, schema, data_format, data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||||
|
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("Records", "Query") => {
|
||||||
|
let query = crate::network::dwn_store::MessageQuery {
|
||||||
|
protocol: message["descriptor"]["filter"]["protocol"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
schema: message["descriptor"]["filter"]["schema"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
author: message["descriptor"]["filter"]["author"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
date_from: message["descriptor"]["filter"]["dateFrom"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
date_to: message["descriptor"]["filter"]["dateTo"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
limit: message["descriptor"]["filter"]["limit"]
|
||||||
|
.as_u64()
|
||||||
|
.map(|n| n as usize),
|
||||||
|
};
|
||||||
|
match store.query_messages(&query).await {
|
||||||
|
Ok(messages) => {
|
||||||
|
serde_json::json!({"status": {"code": 200}, "entries": messages})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("Records", "Read") => {
|
||||||
|
let record_id = message["descriptor"]["recordId"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
match store.read_message(record_id).await {
|
||||||
|
Ok(Some(msg)) => {
|
||||||
|
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||||
|
}
|
||||||
|
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||||
|
Err(e) => {
|
||||||
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("Records", "Delete") => {
|
||||||
|
let record_id = message["descriptor"]["recordId"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
match store.delete_message(record_id).await {
|
||||||
|
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||||
|
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
||||||
|
Err(e) => {
|
||||||
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return single result for single message, array for batch
|
||||||
|
let (response_body, http_status) = if results.len() == 1 {
|
||||||
|
let result = &results[0];
|
||||||
|
let status_code = result["status"]["code"].as_u64().unwrap_or(200);
|
||||||
|
let http_status = match status_code {
|
||||||
|
202 => StatusCode::ACCEPTED,
|
||||||
|
400 => StatusCode::BAD_REQUEST,
|
||||||
|
404 => StatusCode::NOT_FOUND,
|
||||||
|
500 => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
_ => StatusCode::OK,
|
||||||
|
};
|
||||||
|
(result.to_string(), http_status)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
serde_json::json!({"replies": results}).to_string(),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
|
||||||
|
}
|
||||||
|
}
|
||||||
267
core/archipelago/src/api/handler/mod.rs
Normal file
267
core/archipelago/src/api/handler/mod.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
mod content;
|
||||||
|
mod dwn;
|
||||||
|
mod node_message;
|
||||||
|
mod proxy;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::monitoring::MetricsStore;
|
||||||
|
use crate::session::{self, SessionStore};
|
||||||
|
use crate::state::StateManager;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||||
|
// Used by handler submodules after unwrap elimination
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", content_type)
|
||||||
|
.body(body)
|
||||||
|
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiHandler {
|
||||||
|
config: Config,
|
||||||
|
rpc_handler: Arc<RpcHandler>,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
metrics_store: Arc<MetricsStore>,
|
||||||
|
session_store: SessionStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub async fn new(
|
||||||
|
config: Config,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
metrics_store: Arc<MetricsStore>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let session_store = SessionStore::new().await;
|
||||||
|
let rpc_handler = Arc::new(
|
||||||
|
RpcHandler::new(
|
||||||
|
config.clone(),
|
||||||
|
state_manager.clone(),
|
||||||
|
metrics_store.clone(),
|
||||||
|
session_store.clone(),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
rpc_handler,
|
||||||
|
state_manager,
|
||||||
|
metrics_store,
|
||||||
|
session_store,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the RPC handler (for service initialization after construction).
|
||||||
|
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
|
||||||
|
&self.rpc_handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the request has a valid session cookie.
|
||||||
|
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
|
||||||
|
match session::extract_session_cookie(headers) {
|
||||||
|
Some(token) => self.session_store.validate(&token).await,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 401 Unauthorized JSON response.
|
||||||
|
fn unauthorized() -> Response<hyper::Body> {
|
||||||
|
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(body_bytes))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allowed CORS origins derived from the config host IP.
|
||||||
|
fn allowed_origins(&self) -> Vec<String> {
|
||||||
|
let mut origins = vec![
|
||||||
|
format!("http://{}", self.config.host_ip),
|
||||||
|
format!("https://{}", self.config.host_ip),
|
||||||
|
];
|
||||||
|
if self.config.dev_mode {
|
||||||
|
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||||
|
}
|
||||||
|
origins
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the Origin header against allowed origins.
|
||||||
|
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||||
|
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||||
|
let origin = headers
|
||||||
|
.get("origin")
|
||||||
|
.and_then(|v| v.to_str().ok())?;
|
||||||
|
let allowed = self.allowed_origins();
|
||||||
|
if allowed.iter().any(|a| a == origin) {
|
||||||
|
Some(origin.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_request(
|
||||||
|
&self,
|
||||||
|
req: Request<hyper::Body>,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().clone();
|
||||||
|
|
||||||
|
// Handle CORS preflight for all routes
|
||||||
|
if method == Method::OPTIONS {
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.header("Vary", "Origin");
|
||||||
|
if let Some(origin) = self.validate_origin(req.headers()) {
|
||||||
|
builder = builder
|
||||||
|
.header("Access-Control-Allow-Origin", &origin)
|
||||||
|
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
||||||
|
.header("Access-Control-Allow-Credentials", "true");
|
||||||
|
}
|
||||||
|
return Ok(builder.body(hyper::Body::empty()).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket upgrade — validate session before upgrading
|
||||||
|
if method == Method::GET && path == "/ws/db" {
|
||||||
|
if !self.is_authenticated(req.headers()).await {
|
||||||
|
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert body to bytes for non-WS routes
|
||||||
|
let headers = req.headers().clone();
|
||||||
|
let (parts, body) = req.into_parts();
|
||||||
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||||
|
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||||
|
|
||||||
|
debug!("{} {}", method, path);
|
||||||
|
|
||||||
|
match (method, path.as_str()) {
|
||||||
|
// RPC — auth is handled inside rpc handler per-method
|
||||||
|
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||||
|
|
||||||
|
// Health — unauthenticated, returns JSON with service status
|
||||||
|
(Method::GET, "/health") => {
|
||||||
|
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||||
|
let uptime = crate::crash_recovery::uptime_seconds();
|
||||||
|
let health_status = if recovery_complete { "ok" } else { "degraded" };
|
||||||
|
let status = serde_json::json!({
|
||||||
|
"status": health_status,
|
||||||
|
"crash_recovery_complete": recovery_complete,
|
||||||
|
"uptime_seconds": uptime,
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"services": {
|
||||||
|
"rpc": true,
|
||||||
|
"sessions": true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||||
|
(Method::POST, "/archipelago/node-message") => {
|
||||||
|
Self::handle_node_message(body_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content serving — peers access shared content over Tor (no session auth)
|
||||||
|
(Method::GET, p) if p.starts_with("/content/") => {
|
||||||
|
Self::handle_content_request(p, &headers, &self.config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content catalog — list available content (no session auth, for peers)
|
||||||
|
(Method::GET, "/content") => {
|
||||||
|
Self::handle_content_catalog(&self.config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electrs status — unauthenticated (read-only sync status)
|
||||||
|
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||||
|
|
||||||
|
// LND connect info — nginx validates session cookie (presence check),
|
||||||
|
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||||
|
// No backend auth check here because the LND UI iframe fetches this
|
||||||
|
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||||
|
(Method::GET, "/lnd-connect-info") => {
|
||||||
|
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container logs — requires session
|
||||||
|
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||||
|
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// LND proxy — requires session
|
||||||
|
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
let origin = self.validate_origin(&headers).unwrap_or_default();
|
||||||
|
Self::handle_lnd_proxy(path, &origin).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// DWN health — unauthenticated
|
||||||
|
(Method::GET, "/dwn/health") => {
|
||||||
|
Self::handle_dwn_health(&self.config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||||
|
(Method::POST, "/dwn") => {
|
||||||
|
Self::handle_dwn_message(body_bytes, &self.config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Ok(Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(hyper::Body::from("Not Found"))
|
||||||
|
.unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that an app ID matches the safe pattern: lowercase alphanumeric + hyphens.
|
||||||
|
fn is_valid_app_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
&& id.as_bytes()[0] != b'-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a pubkey is a 64-char hex string.
|
||||||
|
fn is_valid_pubkey_hex(s: &str) -> bool {
|
||||||
|
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip newlines and ANSI escape sequences from strings before logging.
|
||||||
|
fn sanitize_log_string(s: &str) -> String {
|
||||||
|
s.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
.replace('\x1b', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip HTML-sensitive characters to prevent XSS when stored/rendered.
|
||||||
|
fn sanitize_html(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
74
core/archipelago/src/api/handler/node_message.rs
Normal file
74
core/archipelago/src/api/handler/node_message.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::node_message as node_msg;
|
||||||
|
use super::build_response;use anyhow::Result;
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
|
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Incoming {
|
||||||
|
from_pubkey: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
signature: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
encrypted: bool,
|
||||||
|
}
|
||||||
|
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||||
|
from_pubkey: None,
|
||||||
|
message: None,
|
||||||
|
signature: None,
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
|
||||||
|
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||||
|
if !is_valid_pubkey_hex(from) {
|
||||||
|
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
|
||||||
|
}
|
||||||
|
// Verify ed25519 signature if provided (required for trusted messages)
|
||||||
|
if let Some(sig_hex) = &incoming.signature {
|
||||||
|
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt if the message is E2E encrypted
|
||||||
|
let plaintext = if incoming.encrypted {
|
||||||
|
// Load our identity to derive shared secret
|
||||||
|
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||||
|
let identity_dir = data_dir.join("identity");
|
||||||
|
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||||
|
Ok(node_id) => {
|
||||||
|
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
||||||
|
Ok(decrypted) => {
|
||||||
|
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
|
||||||
|
decrypted
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
|
||||||
|
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Cannot decrypt: identity load failed: {}", e);
|
||||||
|
msg.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let safe_from = sanitize_log_string(from);
|
||||||
|
let safe_msg = sanitize_log_string(&plaintext);
|
||||||
|
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||||
|
let clean_from = sanitize_html(from);
|
||||||
|
let clean_msg = sanitize_html(&plaintext);
|
||||||
|
node_msg::store_received(&clean_from, &clean_msg).await;
|
||||||
|
}
|
||||||
|
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
|
||||||
|
}
|
||||||
|
}
|
||||||
131
core/archipelago/src/api/handler/proxy.rs
Normal file
131
core/archipelago/src/api/handler/proxy.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use super::build_response;use crate::electrs_status;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::{ApiHandler, is_valid_app_id};
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_container_logs_http(
|
||||||
|
rpc: Arc<RpcHandler>,
|
||||||
|
path: &str,
|
||||||
|
cors_origin: &str,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
let query = path
|
||||||
|
.strip_prefix("/api/container/logs")
|
||||||
|
.and_then(|s| s.strip_prefix('?'))
|
||||||
|
.unwrap_or("");
|
||||||
|
let params: std::collections::HashMap<String, String> =
|
||||||
|
query
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|p| {
|
||||||
|
let mut it = p.splitn(2, '=');
|
||||||
|
let k = it.next()?.to_string();
|
||||||
|
let v = it.next()?.to_string();
|
||||||
|
Some((k, v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||||
|
|
||||||
|
// Validate app_id format
|
||||||
|
if !is_valid_app_id(app_id) {
|
||||||
|
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = params
|
||||||
|
.get("lines")
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.unwrap_or(200);
|
||||||
|
|
||||||
|
match rpc.get_container_logs_value(app_id, lines).await {
|
||||||
|
Ok(value) => {
|
||||||
|
let body = serde_json::json!({ "result": value });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Access-Control-Allow-Origin", cors_origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(hyper::Body::from(body_bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let body = serde_json::json!({ "error": e.to_string() });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Access-Control-Allow-Origin", cors_origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(hyper::Body::from(body_bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||||
|
let status = electrs_status::get_electrs_sync_status().await;
|
||||||
|
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||||
|
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_lnd_connect_info(
|
||||||
|
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
match rpc.handle_lnd_connect_info().await {
|
||||||
|
Ok(val) => {
|
||||||
|
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||||
|
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(
|
||||||
|
serde_json::json!({"error": e.to_string()}).to_string(),
|
||||||
|
))
|
||||||
|
.unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||||
|
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||||
|
match reqwest::get(&url).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let headers = resp.headers().clone();
|
||||||
|
let body = resp.bytes().await.unwrap_or_default();
|
||||||
|
let mut builder = Response::builder().status(status);
|
||||||
|
if let Some(ct) = headers.get("content-type") {
|
||||||
|
if let Ok(s) = ct.to_str() {
|
||||||
|
builder = builder.header("Content-Type", s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.header("Access-Control-Allow-Origin", cors_origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(hyper::Body::from(body))
|
||||||
|
.map_err(|e| anyhow::anyhow!("response build: {}", e))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let body = serde_json::json!({ "error": e.to_string() });
|
||||||
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::BAD_GATEWAY)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Access-Control-Allow-Origin", cors_origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(hyper::Body::from(body_bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
core/archipelago/src/api/handler/websocket.rs
Normal file
128
core/archipelago/src/api/handler/websocket.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use crate::monitoring::MetricsStore;
|
||||||
|
use crate::state::StateManager;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use hyper::{Request, Response};
|
||||||
|
use hyper_ws_listener::WsStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use super::ApiHandler;
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_websocket(
|
||||||
|
req: Request<hyper::Body>,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
metrics_store: Arc<MetricsStore>,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||||
|
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(ws_fut) = ws_fut_opt {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let ws_stream: WsStream = match ws_fut.await {
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!("WebSocket handshake failed (hyper): {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("WebSocket task join failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
metrics_store.increment_ws();
|
||||||
|
info!("WebSocket /ws/db connected");
|
||||||
|
|
||||||
|
let (mut tx, mut rx) = ws_stream.split();
|
||||||
|
|
||||||
|
let initial_msg = state_manager.get_initial_message().await;
|
||||||
|
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||||
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||||
|
debug!("Failed to send initial data: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state_rx = state_manager.subscribe();
|
||||||
|
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||||
|
tokio::pin!(ping_interval);
|
||||||
|
let mut last_client_activity = Instant::now();
|
||||||
|
const INACTIVITY_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
// Check inactivity timeout
|
||||||
|
if last_client_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT_SECS {
|
||||||
|
info!("WebSocket client inactive for {}s, closing", INACTIVITY_TIMEOUT_SECS);
|
||||||
|
let _ = tx.send(Message::Close(None)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if tx.send(Message::Ping(vec![])).await.is_err() {
|
||||||
|
debug!("Failed to send ping, connection likely closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update = state_rx.recv() => {
|
||||||
|
match update {
|
||||||
|
Ok(msg) => {
|
||||||
|
if let Ok(json_msg) = serde_json::to_string(&msg) {
|
||||||
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||||
|
debug!("Failed to send state update: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
debug!("Sent state update at revision {}", msg.rev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
debug!("Client lagged behind, skipped {} messages", skipped);
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
|
debug!("Broadcast channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = rx.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Close(_))) => break,
|
||||||
|
Some(Ok(Message::Pong(_))) => {
|
||||||
|
last_client_activity = Instant::now();
|
||||||
|
debug!("Received pong");
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Ping(data))) => {
|
||||||
|
last_client_activity = Instant::now();
|
||||||
|
let _ = tx.send(Message::Pong(data)).await;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
last_client_activity = Instant::now();
|
||||||
|
// Handle JSON ping from frontend
|
||||||
|
if text.contains("\"type\":\"ping\"") || text.contains("\"type\": \"ping\"") {
|
||||||
|
let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.to_string())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
last_client_activity = Instant::now();
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
debug!("WebSocket stream error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics_store.decrement_ws();
|
||||||
|
info!("WebSocket /ws/db disconnected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
//! Data stays local until explicitly shared via future relay mechanism.
|
//! Data stays local until explicitly shared via future relay mechanism.
|
||||||
|
|
||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tracing::info;
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const ANALYTICS_FILE: &str = "analytics-config.json";
|
const ANALYTICS_FILE: &str = "analytics-config.json";
|
||||||
|
|
||||||
@@ -117,4 +117,322 @@ impl RpcHandler {
|
|||||||
"collected_at": chrono::Utc::now().to_rfc3339(),
|
"collected_at": chrono::Utc::now().to_rfc3339(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a full telemetry report for the beta fleet monitoring.
|
||||||
|
/// Includes health data, container states, errors, and uptime.
|
||||||
|
/// No wallet data, no keys, no personal data — only system health.
|
||||||
|
pub(super) async fn handle_telemetry_report(&self) -> Result<serde_json::Value> {
|
||||||
|
// Check opt-in
|
||||||
|
let config_path = self.config.data_dir.join(ANALYTICS_FILE);
|
||||||
|
let enabled = if config_path.exists() {
|
||||||
|
let data = tokio::fs::read_to_string(&config_path).await?;
|
||||||
|
let config: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
|
||||||
|
config["enabled"].as_bool().unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
anyhow::bail!("Telemetry not enabled. Opt in via analytics.enable first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
|
||||||
|
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
|
||||||
|
let node_id = {
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data.server_info.pubkey.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())[..16].to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Container states
|
||||||
|
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"state": format!("{:?}", pkg.state),
|
||||||
|
"version": pkg.manifest.version,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// System stats
|
||||||
|
let cpu_cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get()).unwrap_or(0);
|
||||||
|
let mem_output = tokio::process::Command::new("grep")
|
||||||
|
.args(["MemTotal", "/proc/meminfo"])
|
||||||
|
.output().await;
|
||||||
|
let total_ram_mb = mem_output.ok()
|
||||||
|
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1)?.parse::<u64>().ok())
|
||||||
|
.map(|kb| kb / 1024).unwrap_or(0);
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||||
|
.map(|f| f as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Recent alerts from metrics store
|
||||||
|
let recent_alerts: Vec<serde_json::Value> = self.metrics_store.get_fired_alerts(10).await
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| serde_json::json!({
|
||||||
|
"rule": format!("{:?}", a.kind),
|
||||||
|
"message": a.message,
|
||||||
|
"timestamp": a.timestamp,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let report = serde_json::json!({
|
||||||
|
"node_id": node_id,
|
||||||
|
"version": data.server_info.version,
|
||||||
|
"uptime_secs": uptime_secs,
|
||||||
|
"cpu_cores": cpu_cores,
|
||||||
|
"ram_mb": total_ram_mb,
|
||||||
|
"containers": containers,
|
||||||
|
"container_count": data.package_data.len(),
|
||||||
|
"running_count": data.package_data.values()
|
||||||
|
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running)).count(),
|
||||||
|
"federation_peers": data.peer_health.len(),
|
||||||
|
"recent_alerts": recent_alerts,
|
||||||
|
"reported_at": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save latest report to disk for debugging
|
||||||
|
let report_path = self.config.data_dir.join("telemetry-latest.json");
|
||||||
|
let _ = tokio::fs::write(&report_path, serde_json::to_string_pretty(&report)?).await;
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fleet telemetry collector endpoints ──────────────────────────────
|
||||||
|
|
||||||
|
/// Receive a telemetry report from a fleet node.
|
||||||
|
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
|
||||||
|
/// Does NOT require auth — called by remote nodes posting reports.
|
||||||
|
pub(super) async fn handle_telemetry_ingest(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let report = params.context("Missing telemetry report payload")?;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
let node_id = report.get("node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing required field: node_id")?;
|
||||||
|
if node_id.is_empty() || node_id.len() > 64 {
|
||||||
|
anyhow::bail!("Invalid node_id: must be 1-64 characters");
|
||||||
|
}
|
||||||
|
// Sanitize node_id to prevent path traversal
|
||||||
|
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
|
||||||
|
anyhow::bail!("Invalid node_id: contains disallowed characters");
|
||||||
|
}
|
||||||
|
let _version = report.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing required field: version")?;
|
||||||
|
let _reported_at = report.get("reported_at")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing required field: reported_at")?;
|
||||||
|
|
||||||
|
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||||
|
tokio::fs::create_dir_all(&fleet_dir).await
|
||||||
|
.context("Failed to create telemetry-fleet directory")?;
|
||||||
|
|
||||||
|
// Write latest report (overwrites previous)
|
||||||
|
let latest_path = fleet_dir.join(format!("{}.json", node_id));
|
||||||
|
let report_json = serde_json::to_string_pretty(&report)
|
||||||
|
.context("Failed to serialize report")?;
|
||||||
|
tokio::fs::write(&latest_path, &report_json).await
|
||||||
|
.context("Failed to write latest fleet report")?;
|
||||||
|
|
||||||
|
// Append to history file (cap at 200 entries)
|
||||||
|
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
|
||||||
|
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
||||||
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
history.push(report.clone());
|
||||||
|
// Keep only the last 200 entries
|
||||||
|
if history.len() > 200 {
|
||||||
|
let start = history.len() - 200;
|
||||||
|
history = history.split_off(start);
|
||||||
|
}
|
||||||
|
let history_json = serde_json::to_string_pretty(&history)
|
||||||
|
.context("Failed to serialize history")?;
|
||||||
|
tokio::fs::write(&history_path, &history_json).await
|
||||||
|
.context("Failed to write fleet history")?;
|
||||||
|
|
||||||
|
debug!(node_id = %node_id, "Ingested fleet telemetry report");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"node_id": node_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all fleet nodes' latest reports.
|
||||||
|
/// Reads all {node_id}.json files from telemetry-fleet/ (excluding *-history.json).
|
||||||
|
pub(super) async fn handle_telemetry_fleet_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||||
|
if !fleet_dir.exists() {
|
||||||
|
return Ok(serde_json::json!({ "nodes": [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
||||||
|
.context("Failed to read telemetry-fleet directory")?;
|
||||||
|
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy();
|
||||||
|
// Skip history files and non-JSON files
|
||||||
|
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::fs::read_to_string(entry.path()).await {
|
||||||
|
Ok(data) => {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&data) {
|
||||||
|
Ok(mut report) => {
|
||||||
|
// Compute online/offline status from reported_at
|
||||||
|
let is_online = report.get("reported_at")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| {
|
||||||
|
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||||
|
age.num_minutes() < 30
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Compute human-readable last_seen
|
||||||
|
let last_seen = report.get("reported_at")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| {
|
||||||
|
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||||
|
let mins = age.num_minutes();
|
||||||
|
if mins < 1 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if mins < 60 {
|
||||||
|
format!("{}m ago", mins)
|
||||||
|
} else if mins < 1440 {
|
||||||
|
format!("{}h ago", mins / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", mins / 1440)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
if let Some(obj) = report.as_object_mut() {
|
||||||
|
obj.insert("online".to_string(), serde_json::json!(is_online));
|
||||||
|
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||||
|
}
|
||||||
|
nodes.push(report);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(file = %name, error = %e, "Failed to read fleet report");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by node_id for stable ordering
|
||||||
|
nodes.sort_by(|a, b| {
|
||||||
|
let a_id = a.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let b_id = b.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
a_id.cmp(b_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(count = nodes.len(), "Fleet status query");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "nodes": nodes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get history for a specific fleet node.
|
||||||
|
/// Reads telemetry-fleet/{node_id}-history.json.
|
||||||
|
pub(super) async fn handle_telemetry_fleet_node_history(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let p = params.context("Missing params")?;
|
||||||
|
let node_id = p.get("node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing required field: node_id")?;
|
||||||
|
|
||||||
|
// Sanitize node_id
|
||||||
|
if node_id.is_empty() || node_id.len() > 64
|
||||||
|
|| node_id.contains('/') || node_id.contains('\\') || node_id.contains("..")
|
||||||
|
{
|
||||||
|
anyhow::bail!("Invalid node_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
let history_path = self.config.data_dir
|
||||||
|
.join("telemetry-fleet")
|
||||||
|
.join(format!("{}-history.json", node_id));
|
||||||
|
|
||||||
|
let history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
||||||
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"node_id": node_id,
|
||||||
|
"entries": history,
|
||||||
|
"count": history.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get aggregated fleet alerts across all nodes.
|
||||||
|
/// Reads all fleet reports, collects recent_alerts, sorts by timestamp descending.
|
||||||
|
pub(super) async fn handle_telemetry_fleet_alerts(&self) -> Result<serde_json::Value> {
|
||||||
|
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||||
|
if !fleet_dir.exists() {
|
||||||
|
return Ok(serde_json::json!({ "alerts": [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
||||||
|
.context("Failed to read telemetry-fleet directory")?;
|
||||||
|
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy();
|
||||||
|
// Only read latest reports, skip history files
|
||||||
|
if name.ends_with("-history.json") || !name.ends_with(".json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = match tokio::fs::read_to_string(entry.path()).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let report: serde_json::Value = match serde_json::from_str(&data) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let node_id = report.get("node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
|
||||||
|
for alert in alerts {
|
||||||
|
let mut enriched = alert.clone();
|
||||||
|
if let Some(obj) = enriched.as_object_mut() {
|
||||||
|
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||||
|
}
|
||||||
|
all_alerts.push(enriched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending (most recent first)
|
||||||
|
all_alerts.sort_by(|a, b| {
|
||||||
|
let a_ts = a.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let b_ts = b.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
b_ts.cmp(&a_ts)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"alerts": all_alerts,
|
||||||
|
"count": all_alerts.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ impl RpcHandler {
|
|||||||
if !is_setup {
|
if !is_setup {
|
||||||
// Dev mode: allow default password so UI can log in without running setup
|
// Dev mode: allow default password so UI can log in without running setup
|
||||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||||
|
tracing::info!("[onboarding] login via dev default password");
|
||||||
return Ok(serde_json::Value::Null);
|
return Ok(serde_json::Value::Null);
|
||||||
}
|
}
|
||||||
|
tracing::warn!("[onboarding] login attempt before setup complete");
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"User not set up. Please complete setup first."
|
"User not set up. Please complete setup first."
|
||||||
));
|
));
|
||||||
@@ -25,13 +27,16 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let valid = self.auth_manager.verify_password(password).await?;
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
if !valid {
|
if !valid {
|
||||||
|
tracing::warn!("[onboarding] login failed — wrong password");
|
||||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!("[onboarding] login successful");
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||||
|
tracing::info!("[onboarding] logout");
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +71,68 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||||
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
|
Ok(serde_json::json!(is_setup))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_setup(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
// Prevent re-setup if already set up
|
||||||
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
|
if is_setup {
|
||||||
|
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||||
|
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||||
|
|
||||||
|
if password.len() < 8 {
|
||||||
|
tracing::warn!("[onboarding] setup rejected — password too short");
|
||||||
|
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.auth_manager.setup_user(password).await?;
|
||||||
|
tracing::info!("[onboarding] user setup complete");
|
||||||
|
Ok(serde_json::json!(true))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
self.auth_manager.complete_onboarding().await?;
|
self.auth_manager.complete_onboarding().await?;
|
||||||
|
tracing::info!("[onboarding] onboarding marked complete");
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
let complete = self.auth_manager.is_onboarding_complete().await?;
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||||
|
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
|
||||||
Ok(serde_json::json!(complete))
|
Ok(serde_json::json!(complete))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_reset_onboarding(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
tracing::warn!("[onboarding] reset rejected — wrong password");
|
||||||
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
self.auth_manager.reset_onboarding().await?;
|
self.auth_manager.reset_onboarding().await?;
|
||||||
|
tracing::info!("[onboarding] onboarding reset");
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,61 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::backup::full;
|
use crate::backup::full;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::net::IpAddr;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
|
||||||
|
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
||||||
|
// Require HTTPS scheme
|
||||||
|
if !endpoint.starts_with("https://") {
|
||||||
|
anyhow::bail!("S3 endpoint must use https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host from URL (strip scheme, path, port)
|
||||||
|
let after_scheme = &endpoint["https://".len()..];
|
||||||
|
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||||
|
// Strip port if present (handle IPv6 bracket notation)
|
||||||
|
let host = if host_port.starts_with('[') {
|
||||||
|
// IPv6: [::1]:443
|
||||||
|
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
|
||||||
|
} else {
|
||||||
|
host_port.split(':').next().unwrap_or("")
|
||||||
|
};
|
||||||
|
|
||||||
|
if host.is_empty() {
|
||||||
|
anyhow::bail!("S3 endpoint missing host");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject localhost
|
||||||
|
if host == "localhost" || host.ends_with(".localhost") {
|
||||||
|
anyhow::bail!("S3 endpoint must not point to localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as IP and reject private/reserved ranges
|
||||||
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
|
let is_private = match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback() // 127.0.0.0/8
|
||||||
|
|| v4.octets()[0] == 10 // 10.0.0.0/8
|
||||||
|
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||||
|
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||||
|
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||||
|
|| v4.is_unspecified() // 0.0.0.0
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback() // ::1
|
||||||
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||||
|
|| v6.is_unspecified() // ::
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if is_private {
|
||||||
|
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||||
pub(super) async fn handle_backup_create(
|
pub(super) async fn handle_backup_create(
|
||||||
@@ -55,6 +108,11 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
|
// Validate backup ID to prevent path traversal
|
||||||
|
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid backup ID");
|
||||||
|
}
|
||||||
|
|
||||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@@ -78,6 +136,11 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
|
// Validate backup ID to prevent path traversal
|
||||||
|
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid backup ID");
|
||||||
|
}
|
||||||
|
|
||||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||||
@@ -183,6 +246,9 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate endpoint to prevent SSRF against internal services
|
||||||
|
validate_s3_endpoint(endpoint)?;
|
||||||
|
|
||||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||||
if !bak_path.exists() {
|
if !bak_path.exists() {
|
||||||
anyhow::bail!("Backup not found: {}", id);
|
anyhow::bail!("Backup not found: {}", id);
|
||||||
@@ -255,6 +321,9 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate endpoint to prevent SSRF against internal services
|
||||||
|
validate_s3_endpoint(endpoint)?;
|
||||||
|
|
||||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ impl RpcHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post("http://127.0.0.1:8332/")
|
.post(crate::constants::BITCOIN_RPC_URL)
|
||||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Validate a v3 Tor onion address.
|
||||||
|
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
|
||||||
|
fn is_valid_v3_onion(addr: &str) -> bool {
|
||||||
|
if addr.len() != 62 || !addr.ends_with(".onion") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let prefix = &addr[..56];
|
||||||
|
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
||||||
|
}
|
||||||
|
|
||||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -25,6 +35,22 @@ impl RpcHandler {
|
|||||||
.get("filename")
|
.get("filename")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
||||||
|
// Validate filename: prevent path traversal and null bytes
|
||||||
|
// Allow forward slashes for subdirectories (e.g., "Music/song.mp3")
|
||||||
|
if filename.contains("..") || filename.contains('\0') || filename.contains('\\') {
|
||||||
|
anyhow::bail!("Invalid filename: path traversal not allowed");
|
||||||
|
}
|
||||||
|
// Reject paths starting with / (absolute) or . (hidden)
|
||||||
|
if filename.starts_with('/') || filename.starts_with('.') {
|
||||||
|
anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed");
|
||||||
|
}
|
||||||
|
// Reject any path segment starting with . (hidden dirs)
|
||||||
|
if filename.split('/').any(|seg| seg.starts_with('.') || seg.is_empty()) {
|
||||||
|
anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed");
|
||||||
|
}
|
||||||
|
if filename.is_empty() || filename.len() > 512 {
|
||||||
|
anyhow::bail!("Invalid filename: must be 1-512 characters");
|
||||||
|
}
|
||||||
let mime_type = params
|
let mime_type = params
|
||||||
.get("mime_type")
|
.get("mime_type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -47,7 +73,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Resolve actual file size from disk
|
// Resolve actual file size from disk
|
||||||
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
let file_path = content_server::content_file_path(&self.config.data_dir, &item);
|
||||||
if let Ok(metadata) = std::fs::metadata(&file_path) {
|
if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
|
||||||
item.size_bytes = metadata.len();
|
item.size_bytes = metadata.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +213,12 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||||
|
|
||||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
if !is_valid_v3_onion(onion) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||||
.context("Failed to create SOCKS proxy")?;
|
.context("Failed to create SOCKS proxy")?;
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -248,13 +275,13 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||||
|
|
||||||
// Validate onion address format
|
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
if !is_valid_v3_onion(onion) {
|
||||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
||||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||||
.context("Failed to create SOCKS proxy")?;
|
.context("Failed to create SOCKS proxy")?;
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
|||||||
399
core/archipelago/src/api/rpc/dispatcher.rs
Normal file
399
core/archipelago/src/api/rpc/dispatcher.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Route an RPC method name to its handler, returning the result value.
|
||||||
|
pub(super) async fn dispatch(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
session_token: &Option<String>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
match method {
|
||||||
|
"echo" => self.handle_echo(params).await,
|
||||||
|
"server.echo" => self.handle_echo(params).await,
|
||||||
|
"health" => self.handle_health().await,
|
||||||
|
"auth.login" => self.handle_auth_login(params).await,
|
||||||
|
"auth.logout" => self.handle_auth_logout().await,
|
||||||
|
"auth.changePassword" => self.handle_auth_change_password(params, session_token).await,
|
||||||
|
"auth.isSetup" => self.handle_auth_is_setup().await,
|
||||||
|
"auth.setup" => self.handle_auth_setup(params).await,
|
||||||
|
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||||
|
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||||
|
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||||
|
|
||||||
|
// Container orchestration (for Archipelago-managed containers)
|
||||||
|
"container-install" => self.handle_container_install(params).await,
|
||||||
|
"container-start" => self.handle_container_start(params).await,
|
||||||
|
"container-stop" => self.handle_container_stop(params).await,
|
||||||
|
"container-remove" => self.handle_container_remove(params).await,
|
||||||
|
"container-list" => self.handle_container_list().await,
|
||||||
|
"container-status" => self.handle_container_status(params).await,
|
||||||
|
"container-logs" => self.handle_container_logs(params).await,
|
||||||
|
"container-health" => self.handle_container_health(params).await,
|
||||||
|
|
||||||
|
// Package management (for docker-compose apps)
|
||||||
|
"package.install" => self.handle_package_install(params).await,
|
||||||
|
"package.start" => self.handle_package_start(params).await,
|
||||||
|
"package.stop" => self.handle_package_stop(params).await,
|
||||||
|
"package.restart" => self.handle_package_restart(params).await,
|
||||||
|
"package.uninstall" => self.handle_package_uninstall(params).await,
|
||||||
|
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||||
|
|
||||||
|
// Bundled app management (for pre-loaded container images)
|
||||||
|
"bundled-app-start" => self.handle_bundled_app_start(params).await,
|
||||||
|
"bundled-app-stop" => self.handle_bundled_app_stop(params).await,
|
||||||
|
|
||||||
|
// Node identity and P2P peers
|
||||||
|
"node-add-peer" => self.handle_node_add_peer(params).await,
|
||||||
|
"node-list-peers" => self.handle_node_list_peers().await,
|
||||||
|
"node-remove-peer" => self.handle_node_remove_peer(params).await,
|
||||||
|
"node-send-message" => self.handle_node_send_message(params).await,
|
||||||
|
"node-check-peer" => self.handle_node_check_peer(params).await,
|
||||||
|
"node-messages-received" => self.handle_node_messages_received().await,
|
||||||
|
"node-store-sent" => self.handle_node_store_sent(params).await,
|
||||||
|
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||||
|
"node.did" => self.handle_node_did().await,
|
||||||
|
"node.signChallenge" => self.handle_node_sign_challenge(params).await,
|
||||||
|
"node.createBackup" => self.handle_node_create_backup(params).await,
|
||||||
|
"node.tor-address" => self.handle_node_tor_address().await,
|
||||||
|
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||||
|
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||||
|
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
||||||
|
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||||
|
"node.rotate-did" => self.handle_node_rotate_did(params).await,
|
||||||
|
|
||||||
|
// Encrypted peer handshake (NIP-44)
|
||||||
|
"handshake.discover" => self.handle_handshake_discover().await,
|
||||||
|
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||||
|
"handshake.poll" => self.handle_handshake_poll().await,
|
||||||
|
|
||||||
|
// TOTP 2FA
|
||||||
|
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||||
|
"auth.totp.setup.confirm" => self.handle_totp_setup_confirm(params).await,
|
||||||
|
"auth.totp.disable" => self.handle_totp_disable(params).await,
|
||||||
|
"auth.totp.status" => self.handle_totp_status().await,
|
||||||
|
"auth.login.totp" => self.handle_login_totp(params, session_token).await,
|
||||||
|
"auth.login.backup" => self.handle_login_backup(params, session_token).await,
|
||||||
|
|
||||||
|
// Bitcoin & Lightning deep data
|
||||||
|
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||||
|
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||||
|
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||||
|
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||||
|
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||||
|
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||||
|
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||||
|
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||||
|
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||||
|
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||||
|
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||||
|
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||||
|
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||||
|
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||||
|
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||||
|
|
||||||
|
// Multi-identity management
|
||||||
|
"identity.list" => self.handle_identity_list(params).await,
|
||||||
|
"identity.create" => self.handle_identity_create(params).await,
|
||||||
|
"identity.get" => self.handle_identity_get(params).await,
|
||||||
|
"identity.delete" => self.handle_identity_delete(params).await,
|
||||||
|
"identity.set-default" => self.handle_identity_set_default(params).await,
|
||||||
|
"identity.sign" => self.handle_identity_sign(params).await,
|
||||||
|
"identity.verify" => self.handle_identity_verify(params).await,
|
||||||
|
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
|
||||||
|
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
|
||||||
|
"identity.verify-did-document" => self.handle_identity_verify_did_document(params).await,
|
||||||
|
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
|
||||||
|
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||||
|
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||||
|
"identity.dht-status" => self.handle_identity_dht_status(params).await,
|
||||||
|
"identity.update-profile" => self.handle_identity_update_profile(params).await,
|
||||||
|
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
|
||||||
|
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||||
|
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||||
|
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||||
|
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
|
||||||
|
"identity.nostr-decrypt-nip04" => self.handle_identity_nostr_decrypt_nip04(params).await,
|
||||||
|
"identity.nostr-encrypt-nip44" => self.handle_identity_nostr_encrypt_nip44(params).await,
|
||||||
|
"identity.nostr-decrypt-nip44" => self.handle_identity_nostr_decrypt_nip44(params).await,
|
||||||
|
|
||||||
|
// Bitcoin domain names (NIP-05)
|
||||||
|
"identity.register-name" => self.handle_identity_register_name(params).await,
|
||||||
|
"identity.remove-name" => self.handle_identity_remove_name(params).await,
|
||||||
|
"identity.resolve-name" => self.handle_identity_resolve_name(params).await,
|
||||||
|
"identity.list-names" => self.handle_identity_list_names(params).await,
|
||||||
|
"identity.link-name" => self.handle_identity_link_name(params).await,
|
||||||
|
|
||||||
|
// Verifiable Credentials
|
||||||
|
"identity.issue-credential" => self.handle_identity_issue_credential(params).await,
|
||||||
|
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
|
||||||
|
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
|
||||||
|
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
|
||||||
|
"identity.create-presentation" => self.handle_identity_create_presentation(params).await,
|
||||||
|
"identity.verify-presentation" => self.handle_identity_verify_presentation(params).await,
|
||||||
|
|
||||||
|
// Network overlay
|
||||||
|
"network.get-visibility" => self.handle_network_get_visibility().await,
|
||||||
|
"network.set-visibility" => self.handle_network_set_visibility(params).await,
|
||||||
|
"network.request-connection" => self.handle_network_request_connection(params).await,
|
||||||
|
"network.list-requests" => self.handle_network_list_requests().await,
|
||||||
|
"network.accept-request" => self.handle_network_accept_request(params).await,
|
||||||
|
"network.reject-request" => self.handle_network_reject_request(params).await,
|
||||||
|
|
||||||
|
// Tor hidden services
|
||||||
|
"tor.list-services" => self.handle_tor_list_services().await,
|
||||||
|
"tor.create-service" => self.handle_tor_create_service(params).await,
|
||||||
|
"tor.delete-service" => self.handle_tor_delete_service(params).await,
|
||||||
|
"tor.get-onion-address" => self.handle_tor_get_onion_address(params).await,
|
||||||
|
"tor.rotate-service" => self.handle_tor_rotate_service(params).await,
|
||||||
|
"tor.cleanup-rotated" => self.handle_tor_cleanup_rotated().await,
|
||||||
|
"tor.toggle-app" => self.handle_tor_toggle_app(params).await,
|
||||||
|
"tor.restart" => self.handle_tor_restart().await,
|
||||||
|
|
||||||
|
// Nostr relay management
|
||||||
|
"nostr.list-relays" => self.handle_nostr_list_relays().await,
|
||||||
|
"nostr.add-relay" => self.handle_nostr_add_relay(params).await,
|
||||||
|
"nostr.remove-relay" => self.handle_nostr_remove_relay(params).await,
|
||||||
|
"nostr.toggle-relay" => self.handle_nostr_toggle_relay(params).await,
|
||||||
|
"nostr.get-stats" => self.handle_nostr_get_stats().await,
|
||||||
|
|
||||||
|
// Router / UPnP
|
||||||
|
"router.discover" => self.handle_router_discover().await,
|
||||||
|
"router.list-forwards" => self.handle_router_list_forwards().await,
|
||||||
|
"router.add-forward" => self.handle_router_add_forward(params).await,
|
||||||
|
"router.remove-forward" => self.handle_router_remove_forward(params).await,
|
||||||
|
"network.diagnostics" => self.handle_network_diagnostics().await,
|
||||||
|
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||||
|
"network.scan-wifi" => self.handle_network_scan_wifi().await,
|
||||||
|
"network.configure-wifi" => self.handle_network_configure_wifi(params).await,
|
||||||
|
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
|
||||||
|
"network.dns-status" => self.handle_network_dns_status().await,
|
||||||
|
"network.configure-dns" => self.handle_network_configure_dns(params).await,
|
||||||
|
"router.detect" => self.handle_router_detect(params).await,
|
||||||
|
"router.info" => self.handle_router_info().await,
|
||||||
|
"router.configure" => self.handle_router_configure(params).await,
|
||||||
|
|
||||||
|
// Ecash wallet
|
||||||
|
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||||
|
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||||
|
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
|
||||||
|
"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.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||||
|
|
||||||
|
// Content catalog management
|
||||||
|
"content.list-mine" => self.handle_content_list_mine().await,
|
||||||
|
"content.add" => self.handle_content_add(params).await,
|
||||||
|
"content.remove" => self.handle_content_remove(params).await,
|
||||||
|
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||||
|
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||||
|
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||||
|
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||||
|
|
||||||
|
// DWN (Decentralized Web Node)
|
||||||
|
"dwn.status" => self.handle_dwn_status().await,
|
||||||
|
"dwn.sync" => self.handle_dwn_sync().await,
|
||||||
|
"dwn.register-protocol" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_dwn_register_protocol(&p).await
|
||||||
|
}
|
||||||
|
"dwn.list-protocols" => self.handle_dwn_list_protocols().await,
|
||||||
|
"dwn.remove-protocol" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_dwn_remove_protocol(&p).await
|
||||||
|
}
|
||||||
|
"dwn.query-messages" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_dwn_query_messages(&p).await
|
||||||
|
}
|
||||||
|
"dwn.write-message" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_dwn_write_message(&p).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Federation
|
||||||
|
"federation.invite" => self.handle_federation_invite().await,
|
||||||
|
"federation.join" => self.handle_federation_join(params).await,
|
||||||
|
"federation.list-nodes" => self.handle_federation_list_nodes().await,
|
||||||
|
"federation.remove-node" => self.handle_federation_remove_node(params).await,
|
||||||
|
"federation.set-trust" => self.handle_federation_set_trust(params).await,
|
||||||
|
"federation.sync-state" => self.handle_federation_sync_state().await,
|
||||||
|
"federation.get-state" => self.handle_federation_get_state().await,
|
||||||
|
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
||||||
|
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
|
||||||
|
"federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await,
|
||||||
|
"federation.notify-did-change" => self.handle_federation_notify_did_change(params).await,
|
||||||
|
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
|
||||||
|
|
||||||
|
// VPN & Remote Access
|
||||||
|
"vpn.status" => self.handle_vpn_status().await,
|
||||||
|
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||||
|
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
||||||
|
"remote.setup" => self.handle_remote_setup(params).await,
|
||||||
|
|
||||||
|
// Marketplace
|
||||||
|
"marketplace.discover" => self.handle_marketplace_discover().await,
|
||||||
|
"marketplace.publish" => self.handle_marketplace_publish(params).await,
|
||||||
|
"marketplace.get-manifest" => self.handle_marketplace_get_manifest(params).await,
|
||||||
|
"marketplace.list-published" => self.handle_marketplace_list_published().await,
|
||||||
|
"marketplace.verify" => self.handle_marketplace_verify(params).await,
|
||||||
|
"marketplace.create-invoice" => self.handle_marketplace_create_invoice(params).await,
|
||||||
|
"marketplace.check-payment" => self.handle_marketplace_check_payment(params).await,
|
||||||
|
|
||||||
|
// Mesh networking (Meshcore LoRa)
|
||||||
|
"mesh.status" => self.handle_mesh_status().await,
|
||||||
|
"mesh.peers" => self.handle_mesh_peers().await,
|
||||||
|
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||||
|
"mesh.send" => self.handle_mesh_send(params).await,
|
||||||
|
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||||
|
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||||
|
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||||
|
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||||
|
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
|
||||||
|
"mesh.outbox" => self.handle_mesh_outbox(params).await,
|
||||||
|
"mesh.session-status" => self.handle_mesh_session_status(params).await,
|
||||||
|
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
|
||||||
|
// Phase 4: Off-grid Bitcoin operations
|
||||||
|
"mesh.relay-tx" => self.handle_mesh_relay_tx(params).await,
|
||||||
|
"mesh.relay-status" => self.handle_mesh_relay_status(params).await,
|
||||||
|
"mesh.block-headers" => self.handle_mesh_block_headers(params).await,
|
||||||
|
"mesh.relay-lightning" => self.handle_mesh_relay_lightning(params).await,
|
||||||
|
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
||||||
|
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
||||||
|
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
|
||||||
|
"mesh.test-send" => self.handle_mesh_test_send(params).await,
|
||||||
|
|
||||||
|
// Transport layer (unified routing)
|
||||||
|
"transport.status" => self.handle_transport_status().await,
|
||||||
|
"transport.peers" => self.handle_transport_peers().await,
|
||||||
|
"transport.send" => self.handle_transport_send(params).await,
|
||||||
|
"transport.set-mode" => self.handle_transport_set_mode(params).await,
|
||||||
|
|
||||||
|
// Server settings
|
||||||
|
"server.set-name" => self.handle_server_set_name(params).await,
|
||||||
|
|
||||||
|
// System monitoring
|
||||||
|
"system.stats" => self.handle_system_stats().await,
|
||||||
|
"system.processes" => self.handle_system_processes().await,
|
||||||
|
"system.temperature" => self.handle_system_temperature().await,
|
||||||
|
"system.detect-usb-devices" => self.handle_system_detect_usb_devices().await,
|
||||||
|
"system.disk-status" => self.handle_system_disk_status().await,
|
||||||
|
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||||
|
"system.reboot" => self.handle_system_reboot(params).await,
|
||||||
|
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||||
|
|
||||||
|
// Opt-in anonymous analytics
|
||||||
|
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||||
|
"analytics.enable" => self.handle_analytics_enable().await,
|
||||||
|
"analytics.disable" => self.handle_analytics_disable().await,
|
||||||
|
"analytics.get-snapshot" => self.handle_analytics_get_snapshot().await,
|
||||||
|
"telemetry.report" => self.handle_telemetry_report().await,
|
||||||
|
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
|
||||||
|
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
|
||||||
|
"telemetry.fleet-node-history" => self.handle_telemetry_fleet_node_history(params).await,
|
||||||
|
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
|
||||||
|
|
||||||
|
// Real-time metrics monitoring
|
||||||
|
"monitoring.current" => self.handle_monitoring_current().await,
|
||||||
|
"monitoring.history" => self.handle_monitoring_history(params).await,
|
||||||
|
"monitoring.containers" => self.handle_monitoring_containers().await,
|
||||||
|
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
|
||||||
|
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
|
||||||
|
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
|
||||||
|
"monitoring.acknowledge-alert" => self.handle_monitoring_acknowledge_alert(params).await,
|
||||||
|
"monitoring.export" => self.handle_monitoring_export(params).await,
|
||||||
|
|
||||||
|
// System updates
|
||||||
|
"update.check" => self.handle_update_check().await,
|
||||||
|
"update.status" => self.handle_update_status().await,
|
||||||
|
"update.dismiss" => self.handle_update_dismiss().await,
|
||||||
|
"update.download" => self.handle_update_download().await,
|
||||||
|
"update.apply" => self.handle_update_apply().await,
|
||||||
|
"update.git-apply" => self.handle_update_git_apply().await,
|
||||||
|
"update.rollback" => self.handle_update_rollback().await,
|
||||||
|
"update.get-schedule" => self.handle_update_get_schedule().await,
|
||||||
|
"update.set-schedule" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_update_set_schedule(&p).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup & Restore
|
||||||
|
"backup.create" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_create(&p).await
|
||||||
|
}
|
||||||
|
"backup.list" => self.handle_backup_list().await,
|
||||||
|
"backup.verify" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_verify(&p).await
|
||||||
|
}
|
||||||
|
"backup.restore" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_restore(&p).await
|
||||||
|
}
|
||||||
|
"backup.restore-identity" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_restore_identity(&p).await
|
||||||
|
}
|
||||||
|
"backup.delete" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_delete(&p).await
|
||||||
|
}
|
||||||
|
"backup.list-drives" => self.handle_backup_list_drives().await,
|
||||||
|
"backup.to-usb" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_to_usb(&p).await
|
||||||
|
}
|
||||||
|
"backup.upload-s3" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_upload_s3(&p).await
|
||||||
|
}
|
||||||
|
"backup.download-s3" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_backup_download_s3(&p).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security / secrets
|
||||||
|
"security.rotate-secrets" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_security_rotate_secrets(&p).await
|
||||||
|
}
|
||||||
|
"security.list-expiring" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_security_list_expiring(&p).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
"webhook.get-config" => self.handle_webhook_get_config().await,
|
||||||
|
"webhook.configure" => self.handle_webhook_configure(params).await,
|
||||||
|
"webhook.test" => self.handle_webhook_test().await,
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Err(anyhow::anyhow!("Unknown method: {}", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
if let Some(p) = params {
|
||||||
|
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||||
|
return Ok(serde_json::json!({ "message": msg }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||||
|
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||||
|
let uptime = crate::crash_recovery::uptime_seconds();
|
||||||
|
let status = if recovery_complete { "ok" } else { "degraded" };
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"crash_recovery_complete": recovery_complete,
|
||||||
|
"uptime_seconds": uptime,
|
||||||
|
"version": format!("{}-{}", env!("CARGO_PKG_VERSION"), option_env!("GIT_HASH").unwrap_or("dev")),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,17 @@
|
|||||||
use super::RpcHandler;
|
use super::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::credentials;
|
use crate::credentials;
|
||||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
use crate::identity_manager::IdentityManager;
|
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||||
|
|
||||||
/// Validate a DID parameter: must start with "did:", max 256 chars, no path traversal.
|
|
||||||
fn validate_did(did: &str) -> Result<()> {
|
|
||||||
if did.is_empty() || did.len() > 256 {
|
|
||||||
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
|
||||||
}
|
|
||||||
if !did.starts_with("did:") {
|
|
||||||
anyhow::bail!("Invalid DID: must start with 'did:'");
|
|
||||||
}
|
|
||||||
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
|
||||||
anyhow::bail!("Invalid DID: contains forbidden characters");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||||
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let onion = data
|
let onion = data
|
||||||
@@ -50,7 +36,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.join — Accept an invite code and establish federation with the remote node.
|
/// federation.join — Accept an invite code and establish federation with the remote node.
|
||||||
pub(super) async fn handle_federation_join(
|
pub(in crate::api::rpc) async fn handle_federation_join(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -65,12 +51,15 @@ impl RpcHandler {
|
|||||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||||
let local_pubkey = data.server_info.pubkey.clone();
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
let node = federation::accept_invite(
|
let node = federation::accept_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
code,
|
code,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
|data| node_identity.sign(data),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -147,7 +136,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
||||||
pub(super) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
// Load credentials to check for federation VCs
|
// Load credentials to check for federation VCs
|
||||||
@@ -194,7 +183,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.remove-node — Remove a node from the federation by DID.
|
/// federation.remove-node — Remove a node from the federation by DID.
|
||||||
pub(super) async fn handle_federation_remove_node(
|
pub(in crate::api::rpc) async fn handle_federation_remove_node(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -215,7 +204,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.set-trust — Change trust level for a federated node.
|
/// federation.set-trust — Change trust level for a federated node.
|
||||||
pub(super) async fn handle_federation_set_trust(
|
pub(in crate::api::rpc) async fn handle_federation_set_trust(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -247,7 +236,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
||||||
pub(super) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
if nodes.is_empty() {
|
if nodes.is_empty() {
|
||||||
@@ -309,7 +298,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
||||||
pub(super) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
|
||||||
// Build app statuses from package_data
|
// Build app statuses from package_data
|
||||||
@@ -325,15 +314,17 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let tor_active = data.server_info.tor_address.is_some();
|
let tor_active = data.server_info.tor_address.is_some();
|
||||||
|
|
||||||
|
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
||||||
let state = federation::build_local_state(
|
let state = federation::build_local_state(
|
||||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active,
|
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(serde_json::to_value(&state)?)
|
Ok(serde_json::to_value(&state)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
||||||
pub(super) async fn handle_federation_peer_joined(
|
/// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_peer_joined(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -351,6 +342,27 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||||
|
|
||||||
|
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||||
|
let signature = params
|
||||||
|
.get("signature")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
match signature {
|
||||||
|
Some(sig) => {
|
||||||
|
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
||||||
|
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
|
||||||
|
anyhow::bail!("Invalid signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
||||||
|
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
if nodes.iter().any(|n| n.did == did) {
|
if nodes.iter().any(|n| n.did == did) {
|
||||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||||
@@ -374,7 +386,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.deploy-app — Deploy an app to a remote federated node.
|
/// federation.deploy-app — Deploy an app to a remote federated node.
|
||||||
pub(super) async fn handle_federation_deploy_app(
|
pub(in crate::api::rpc) async fn handle_federation_deploy_app(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -423,7 +435,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
||||||
pub(super) async fn handle_federation_peer_address_changed(
|
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
|
||||||
|
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_peer_address_changed(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -436,17 +450,31 @@ impl RpcHandler {
|
|||||||
.get("new_onion")
|
.get("new_onion")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
||||||
|
let signature = params
|
||||||
|
.get("signature")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing signature — address changes must be signed"))?;
|
||||||
|
|
||||||
// Load existing nodes, find the peer by DID, update their onion
|
// Load existing nodes, find the peer by DID
|
||||||
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
let found = nodes.iter_mut().find(|n| n.did == did);
|
let found = nodes.iter_mut().find(|n| n.did == did);
|
||||||
|
|
||||||
match found {
|
match found {
|
||||||
Some(node) => {
|
Some(node) => {
|
||||||
|
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
|
||||||
|
let sign_data = format!("address-changed:{}:{}", did, new_onion);
|
||||||
|
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(did = %did, "Rejected address change: invalid signature");
|
||||||
|
anyhow::bail!("Invalid signature — address change rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let old = node.onion.clone();
|
let old = node.onion.clone();
|
||||||
node.onion = new_onion.to_string();
|
node.onion = new_onion.to_string();
|
||||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||||
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address");
|
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address (signature verified)");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"updated": true,
|
"updated": true,
|
||||||
"did": did,
|
"did": did,
|
||||||
@@ -463,4 +491,225 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// federation.notify-did-change — Notify all federated peers that our DID has rotated.
|
||||||
|
/// Called after `node.rotate-did` to propagate the rotation proof to peers.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_notify_did_change(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let old_did = params
|
||||||
|
.get("old_did")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?;
|
||||||
|
let new_did = params
|
||||||
|
.get("new_did")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?;
|
||||||
|
let proof_signature = params
|
||||||
|
.get("proof_signature")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'proof_signature'"))?;
|
||||||
|
let proof_message = params
|
||||||
|
.get("proof_message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'proof_message'"))?;
|
||||||
|
|
||||||
|
validate_did(old_did)?;
|
||||||
|
validate_did(new_did)?;
|
||||||
|
|
||||||
|
// Get the new pubkey to include in the notification
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let new_pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||||
|
.context("Invalid Tor proxy")?;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.proxy(proxy)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.context("Failed to build HTTP client")?;
|
||||||
|
|
||||||
|
let mut notified = 0u32;
|
||||||
|
let mut failed = 0u32;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for node in &nodes {
|
||||||
|
// Only notify trusted and observer peers
|
||||||
|
if node.trust_level == TrustLevel::Untrusted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = if node.onion.ends_with(".onion") {
|
||||||
|
node.onion.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}.onion", node.onion)
|
||||||
|
};
|
||||||
|
let url = format!("http://{}/rpc/v1", host);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"method": "federation.peer-did-changed",
|
||||||
|
"params": {
|
||||||
|
"old_did": old_did,
|
||||||
|
"new_did": new_did,
|
||||||
|
"new_pubkey": new_pubkey,
|
||||||
|
"signature": proof_signature,
|
||||||
|
"proof_message": proof_message,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match client.post(&url).json(&body).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
notified += 1;
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"did": node.did,
|
||||||
|
"status": "ok",
|
||||||
|
}));
|
||||||
|
info!(peer_did = %node.did, "Notified peer of DID rotation");
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
failed += 1;
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"did": node.did,
|
||||||
|
"status": "error",
|
||||||
|
"error": format!("Peer returned {}", resp.status()),
|
||||||
|
}));
|
||||||
|
warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
failed += 1;
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"did": node.did,
|
||||||
|
"status": "error",
|
||||||
|
"error": e.to_string(),
|
||||||
|
}));
|
||||||
|
warn!(peer_did = %node.did, error = %e, "Failed to notify peer of DID rotation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"notified": notified,
|
||||||
|
"failed": failed,
|
||||||
|
"results": results,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// federation.peer-did-changed — A peer notifies us that their DID has rotated.
|
||||||
|
/// Verifies the rotation proof against the peer's KNOWN pubkey before accepting.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_peer_did_changed(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let old_did = params
|
||||||
|
.get("old_did")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?;
|
||||||
|
let new_did = params
|
||||||
|
.get("new_did")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?;
|
||||||
|
let new_pubkey = params
|
||||||
|
.get("new_pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'new_pubkey'"))?;
|
||||||
|
let signature = params
|
||||||
|
.get("signature")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'signature'"))?;
|
||||||
|
|
||||||
|
validate_did(old_did)?;
|
||||||
|
validate_did(new_did)?;
|
||||||
|
|
||||||
|
// Validate new_pubkey is a valid 32-byte hex-encoded Ed25519 public key
|
||||||
|
let pubkey_bytes = hex::decode(new_pubkey)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid new_pubkey: not valid hex"))?;
|
||||||
|
if pubkey_bytes.len() != 32 {
|
||||||
|
anyhow::bail!("Invalid new_pubkey: must be 32 bytes (64 hex chars)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate signature is valid hex of correct length (64 bytes = 128 hex chars)
|
||||||
|
let sig_bytes = hex::decode(signature)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid signature: not valid hex"))?;
|
||||||
|
if sig_bytes.len() != 64 {
|
||||||
|
anyhow::bail!("Invalid signature: must be 64 bytes (128 hex chars)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the new_did matches the new_pubkey
|
||||||
|
let expected_new_did = identity::did_key_from_pubkey_hex(new_pubkey)?;
|
||||||
|
if expected_new_did != new_did {
|
||||||
|
anyhow::bail!("new_did does not match new_pubkey");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing nodes, find the peer by their OLD DID
|
||||||
|
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
let found = nodes.iter_mut().find(|n| n.did == old_did);
|
||||||
|
|
||||||
|
match found {
|
||||||
|
Some(node) => {
|
||||||
|
// Verify the rotation proof: the old key signed
|
||||||
|
// "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender
|
||||||
|
// forwards both the signature and the full proof_message.
|
||||||
|
let proof_message = params
|
||||||
|
.get("proof_message")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let verified = if let Some(msg) = proof_message {
|
||||||
|
// Verify the proof_message starts with the expected prefix
|
||||||
|
let expected_prefix = format!("did-rotate:{}:{}:", old_did, new_did);
|
||||||
|
if !msg.starts_with(&expected_prefix) {
|
||||||
|
warn!(old_did = %old_did, "Rejected DID rotation: proof_message has wrong prefix");
|
||||||
|
anyhow::bail!("Invalid proof_message format");
|
||||||
|
}
|
||||||
|
// Verify signature against the full proof_message using the KNOWN pubkey
|
||||||
|
matches!(
|
||||||
|
identity::NodeIdentity::verify(&node.pubkey, msg.as_bytes(), signature),
|
||||||
|
Ok(true)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Fallback: verify without timestamp (backwards-compatible)
|
||||||
|
let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did);
|
||||||
|
matches!(
|
||||||
|
identity::NodeIdentity::verify(&node.pubkey, fallback_msg.as_bytes(), signature),
|
||||||
|
Ok(true)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
warn!(old_did = %old_did, "Rejected DID rotation: invalid signature");
|
||||||
|
anyhow::bail!("Invalid signature — DID rotation rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_pubkey = node.pubkey.clone();
|
||||||
|
node.did = new_did.to_string();
|
||||||
|
node.pubkey = new_pubkey.to_string();
|
||||||
|
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
old_did = %old_did,
|
||||||
|
new_did = %new_did,
|
||||||
|
old_pubkey = %old_pubkey,
|
||||||
|
"Updated federated peer DID (rotation signature verified)"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"updated": true,
|
||||||
|
"old_did": old_did,
|
||||||
|
"new_did": new_did,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!(old_did = %old_did, "Received DID rotation from unknown peer — ignoring");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"updated": false,
|
||||||
|
"reason": "Unknown peer DID",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
17
core/archipelago/src/api/rpc/federation/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub(super) fn validate_did(did: &str) -> Result<()> {
|
||||||
|
if did.is_empty() || did.len() > 256 {
|
||||||
|
anyhow::bail!("Invalid DID: must be 1-256 characters");
|
||||||
|
}
|
||||||
|
if !did.starts_with("did:") {
|
||||||
|
anyhow::bail!("Invalid DID: must start with 'did:'");
|
||||||
|
}
|
||||||
|
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid DID: contains forbidden characters");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ impl RpcHandler {
|
|||||||
&identity_dir,
|
&identity_dir,
|
||||||
&self.config.nostr_relays,
|
&self.config.nostr_relays,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
None, // TODO: track last-seen timestamp to avoid re-processing
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
//! RPC handlers for multi-identity management.
|
use super::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
use super::RpcHandler;
|
|
||||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||||
use crate::network::did_dht;
|
use crate::network::did_dht;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
|
|
||||||
/// Validate an identity ID: alphanumeric, hyphens, underscores, 1-128 chars, no path traversal.
|
|
||||||
fn validate_identity_id(id: &str) -> Result<()> {
|
|
||||||
if id.is_empty() || id.len() > 128 {
|
|
||||||
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
|
||||||
}
|
|
||||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
|
||||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
|
||||||
}
|
|
||||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
|
||||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List all identities with their default status.
|
/// List all identities with their default status.
|
||||||
pub(super) async fn handle_identity_list(
|
pub(in crate::api::rpc) async fn handle_identity_list(
|
||||||
&self,
|
&self,
|
||||||
_params: Option<serde_json::Value>,
|
_params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -52,7 +37,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new identity.
|
/// Create a new identity.
|
||||||
pub(super) async fn handle_identity_create(
|
pub(in crate::api::rpc) async fn handle_identity_create(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -60,8 +45,11 @@ impl RpcHandler {
|
|||||||
let name = params
|
let name = params
|
||||||
.get("name")
|
.get("name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Personal")
|
.unwrap_or("Personal");
|
||||||
.to_string();
|
if name.len() > 100 {
|
||||||
|
anyhow::bail!("Identity name must be 100 characters or fewer");
|
||||||
|
}
|
||||||
|
let name = name.to_string();
|
||||||
|
|
||||||
let purpose_str = params
|
let purpose_str = params
|
||||||
.get("purpose")
|
.get("purpose")
|
||||||
@@ -90,7 +78,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single identity by ID.
|
/// Get a single identity by ID.
|
||||||
pub(super) async fn handle_identity_get(
|
pub(in crate::api::rpc) async fn handle_identity_get(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -120,7 +108,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an identity.
|
/// Delete an identity.
|
||||||
pub(super) async fn handle_identity_delete(
|
pub(in crate::api::rpc) async fn handle_identity_delete(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -138,7 +126,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the default identity.
|
/// Set the default identity.
|
||||||
pub(super) async fn handle_identity_set_default(
|
pub(in crate::api::rpc) async fn handle_identity_set_default(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -156,7 +144,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign a message with a specific identity.
|
/// Sign a message with a specific identity.
|
||||||
pub(super) async fn handle_identity_sign(
|
pub(in crate::api::rpc) async fn handle_identity_sign(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -182,7 +170,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a signature against a DID.
|
/// Verify a signature against a DID.
|
||||||
pub(super) async fn handle_identity_verify(
|
pub(in crate::api::rpc) async fn handle_identity_verify(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -208,7 +196,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Resolve a DID to its W3C DID Document.
|
/// Resolve a DID to its W3C DID Document.
|
||||||
/// If no DID is provided, returns the node's own DID Document.
|
/// If no DID is provided, returns the node's own DID Document.
|
||||||
pub(super) async fn handle_identity_resolve_did(
|
pub(in crate::api::rpc) async fn handle_identity_resolve_did(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -240,7 +228,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a DID Document: validate structure, check key material matches DID.
|
/// Verify a DID Document: validate structure, check key material matches DID.
|
||||||
pub(super) async fn handle_identity_verify_did_document(
|
pub(in crate::api::rpc) async fn handle_identity_verify_did_document(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -312,7 +300,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Nostr keypair linked to an identity.
|
/// Create a Nostr keypair linked to an identity.
|
||||||
pub(super) async fn handle_identity_create_nostr_key(
|
pub(in crate::api::rpc) async fn handle_identity_create_nostr_key(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -342,7 +330,7 @@ impl RpcHandler {
|
|||||||
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
/// - `event_hash` (hex) + `id` — sign a pre-computed hash
|
||||||
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
/// - `event` (full event object) — compute NIP-01 hash, fill pubkey, sign
|
||||||
/// If `id` is omitted, uses the default identity.
|
/// If `id` is omitted, uses the default identity.
|
||||||
pub(super) async fn handle_identity_nostr_sign(
|
pub(in crate::api::rpc) async fn handle_identity_nostr_sign(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -426,7 +414,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-04 encrypt plaintext for a peer.
|
/// NIP-04 encrypt plaintext for a peer.
|
||||||
pub(super) async fn handle_identity_nostr_encrypt_nip04(
|
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip04(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -444,7 +432,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-04 decrypt ciphertext from a peer.
|
/// NIP-04 decrypt ciphertext from a peer.
|
||||||
pub(super) async fn handle_identity_nostr_decrypt_nip04(
|
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip04(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -462,7 +450,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-44 encrypt plaintext for a peer.
|
/// NIP-44 encrypt plaintext for a peer.
|
||||||
pub(super) async fn handle_identity_nostr_encrypt_nip44(
|
pub(in crate::api::rpc) async fn handle_identity_nostr_encrypt_nip44(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -480,7 +468,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-44 decrypt ciphertext from a peer.
|
/// NIP-44 decrypt ciphertext from a peer.
|
||||||
pub(super) async fn handle_identity_nostr_decrypt_nip44(
|
pub(in crate::api::rpc) async fn handle_identity_nostr_decrypt_nip44(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -499,7 +487,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Resolve a remote peer's DID Document over Tor.
|
/// Resolve a remote peer's DID Document over Tor.
|
||||||
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
||||||
pub(super) async fn handle_identity_resolve_remote_did(
|
pub(in crate::api::rpc) async fn handle_identity_resolve_remote_did(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -518,7 +506,7 @@ impl RpcHandler {
|
|||||||
let url = format!("http://{}/rpc/", host);
|
let url = format!("http://{}/rpc/", host);
|
||||||
|
|
||||||
// Use SOCKS5 proxy to reach .onion address
|
// Use SOCKS5 proxy to reach .onion address
|
||||||
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||||
.context("Failed to create Tor proxy")?;
|
.context("Failed to create Tor proxy")?;
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.proxy(proxy)
|
.proxy(proxy)
|
||||||
@@ -575,7 +563,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
|
/// identity.create-dht-did — Publish an identity's DID to the Mainline DHT.
|
||||||
pub(super) async fn handle_identity_create_dht_did(
|
pub(in crate::api::rpc) async fn handle_identity_create_dht_did(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -601,7 +589,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
/// identity.resolve-dht-did — Resolve a did:dht from the DHT.
|
||||||
pub(super) async fn handle_identity_resolve_dht_did(
|
pub(in crate::api::rpc) async fn handle_identity_resolve_dht_did(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -624,7 +612,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
|
/// identity.refresh-dht-did — Re-publish an identity's did:dht to keep it alive in the DHT.
|
||||||
pub(super) async fn handle_identity_refresh_dht_did(
|
pub(in crate::api::rpc) async fn handle_identity_refresh_dht_did(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -652,7 +640,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update profile metadata for an identity.
|
/// Update profile metadata for an identity.
|
||||||
pub(super) async fn handle_identity_update_profile(
|
pub(in crate::api::rpc) async fn handle_identity_update_profile(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -678,7 +666,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||||
pub(super) async fn handle_identity_publish_profile(
|
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -702,7 +690,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Export private keys for an identity — REQUIRES password verification.
|
/// Export private keys for an identity — REQUIRES password verification.
|
||||||
pub(super) async fn handle_identity_export_keys(
|
pub(in crate::api::rpc) async fn handle_identity_export_keys(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
@@ -740,7 +728,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||||
pub(super) async fn handle_identity_dht_status(
|
pub(in crate::api::rpc) async fn handle_identity_dht_status(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
17
core/archipelago/src/api/rpc/identity/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub(super) fn validate_identity_id(id: &str) -> Result<()> {
|
||||||
|
if id.is_empty() || id.len() > 128 {
|
||||||
|
anyhow::bail!("Invalid identity id: must be 1-128 characters");
|
||||||
|
}
|
||||||
|
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||||
|
}
|
||||||
|
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
||||||
|
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,996 +0,0 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use base64::Engine;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct LndInfo {
|
|
||||||
alias: String,
|
|
||||||
num_active_channels: u32,
|
|
||||||
num_peers: u32,
|
|
||||||
synced_to_chain: bool,
|
|
||||||
block_height: u64,
|
|
||||||
balance_sats: i64,
|
|
||||||
channel_balance_sats: i64,
|
|
||||||
pending_open_balance: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndGetInfoResponse {
|
|
||||||
alias: Option<String>,
|
|
||||||
num_active_channels: Option<u32>,
|
|
||||||
num_peers: Option<u32>,
|
|
||||||
synced_to_chain: Option<bool>,
|
|
||||||
block_height: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndChannelBalanceResponse {
|
|
||||||
local_balance: Option<LndAmount>,
|
|
||||||
pending_open_local_balance: Option<LndAmount>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndBalanceResponse {
|
|
||||||
total_balance: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
confirmed_balance: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndAmount {
|
|
||||||
sat: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcHandler {
|
|
||||||
pub(super) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
|
||||||
let macaroon_path =
|
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
|
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
|
||||||
.await
|
|
||||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
|
||||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.danger_accept_invalid_certs(true)
|
|
||||||
.build()
|
|
||||||
.context("Failed to create HTTP client")?;
|
|
||||||
|
|
||||||
let get_info: LndGetInfoResponse = client
|
|
||||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("LND REST connection failed")?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse LND getinfo response")?;
|
|
||||||
|
|
||||||
let channel_balance: LndChannelBalanceResponse = match client
|
|
||||||
.get("https://127.0.0.1:8080/v1/balance/channels")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
|
||||||
local_balance: None,
|
|
||||||
pending_open_local_balance: None,
|
|
||||||
}),
|
|
||||||
Err(_) => LndChannelBalanceResponse {
|
|
||||||
local_balance: None,
|
|
||||||
pending_open_local_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let wallet_balance: LndBalanceResponse = match client
|
|
||||||
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
confirmed_balance: None,
|
|
||||||
}),
|
|
||||||
Err(_) => LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
confirmed_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let info = LndInfo {
|
|
||||||
alias: get_info.alias.unwrap_or_default(),
|
|
||||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
|
||||||
num_peers: get_info.num_peers.unwrap_or(0),
|
|
||||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
|
||||||
block_height: get_info.block_height.unwrap_or(0),
|
|
||||||
balance_sats: wallet_balance
|
|
||||||
.total_balance
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(0),
|
|
||||||
channel_balance_sats: channel_balance
|
|
||||||
.local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
pending_open_balance: channel_balance
|
|
||||||
.pending_open_local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(serde_json::to_value(info)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: create an authenticated LND REST client
|
|
||||||
async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
|
||||||
let macaroon_path =
|
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
|
||||||
.await
|
|
||||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
|
||||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
|
||||||
.danger_accept_invalid_certs(true)
|
|
||||||
.build()
|
|
||||||
.context("Failed to create HTTP client")?;
|
|
||||||
Ok((client, macaroon_hex))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let channels_resp: LndListChannelsResponse = client
|
|
||||||
.get("https://127.0.0.1:8080/v1/channels")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("LND REST connection failed")?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse LND channels response")?;
|
|
||||||
|
|
||||||
let pending_resp: LndPendingChannelsResponse = match client
|
|
||||||
.get("https://127.0.0.1:8080/v1/channels/pending")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.json().await.unwrap_or_default(),
|
|
||||||
Err(_) => LndPendingChannelsResponse::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let channels: Vec<ChannelInfo> = channels_resp
|
|
||||||
.channels
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|ch| {
|
|
||||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
ChannelInfo {
|
|
||||||
chan_id: ch.chan_id.unwrap_or_default(),
|
|
||||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
|
||||||
capacity,
|
|
||||||
local_balance: local,
|
|
||||||
remote_balance: remote,
|
|
||||||
active: ch.active.unwrap_or(false),
|
|
||||||
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
|
|
||||||
channel_point: ch.channel_point.unwrap_or_default(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
|
||||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
|
||||||
if let Some(ch) = pch.channel {
|
|
||||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
||||||
pending_channels.push(ChannelInfo {
|
|
||||||
chan_id: String::new(),
|
|
||||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
|
||||||
capacity,
|
|
||||||
local_balance: local,
|
|
||||||
remote_balance: remote,
|
|
||||||
active: false,
|
|
||||||
status: "pending_open".into(),
|
|
||||||
channel_point: ch.channel_point.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
|
||||||
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
|
||||||
|
|
||||||
let mut all_channels = channels;
|
|
||||||
all_channels.extend(pending_channels);
|
|
||||||
|
|
||||||
let result = ChannelListResult {
|
|
||||||
channels: all_channels,
|
|
||||||
total_inbound: total_remote,
|
|
||||||
total_outbound: total_local,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(serde_json::to_value(result)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let pubkey = params.get("pubkey")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
|
||||||
let amount = params.get("amount")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
|
||||||
|
|
||||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
|
||||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if amount < 20000 {
|
|
||||||
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
|
|
||||||
}
|
|
||||||
if amount > 16_777_215 {
|
|
||||||
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
// First connect to the peer if an address is provided
|
|
||||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
|
||||||
// Validate peer address format (host:port)
|
|
||||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
|
||||||
return Err(anyhow::anyhow!("Invalid peer address format"));
|
|
||||||
}
|
|
||||||
let connect_body = serde_json::json!({
|
|
||||||
"addr": { "pubkey": pubkey, "host": addr },
|
|
||||||
"perm": true
|
|
||||||
});
|
|
||||||
let _ = client
|
|
||||||
.post("https://127.0.0.1:8080/v1/peers")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&connect_body)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let open_body = serde_json::json!({
|
|
||||||
"node_pubkey_string": pubkey,
|
|
||||||
"local_funding_amount": amount.to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v1/channels")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&open_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to open channel")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let channel_point = params.get("channel_point")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
|
|
||||||
|
|
||||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
|
||||||
if parts.len() != 2 {
|
|
||||||
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
|
|
||||||
}
|
|
||||||
// Validate txid is 64-char hex and output_index is numeric
|
|
||||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
|
|
||||||
}
|
|
||||||
if parts[1].parse::<u32>().is_err() {
|
|
||||||
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
||||||
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
|
||||||
parts[0], parts[1], force
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.delete(&url)
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to close channel")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a new on-chain Bitcoin address.
|
|
||||||
pub(super) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://127.0.0.1:8080/v1/newaddress")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("LND REST connection failed")?;
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse newaddress response")?;
|
|
||||||
|
|
||||||
let address = body.get("address")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "address": address }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send on-chain Bitcoin to an address.
|
|
||||||
pub(super) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let addr = params.get("addr")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
|
||||||
let amount = params.get("amount")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
|
||||||
|
|
||||||
if amount < 546 {
|
|
||||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
|
||||||
}
|
|
||||||
if amount > 21_000_000 * 100_000_000 {
|
|
||||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Bitcoin address format (basic: length and allowed chars)
|
|
||||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
||||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let send_body = serde_json::json!({
|
|
||||||
"addr": addr,
|
|
||||||
"amount": amount.to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v1/transactions")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&send_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to send on-chain transaction")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse send response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
||||||
Ok(serde_json::json!({ "txid": txid }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a Lightning invoice.
|
|
||||||
pub(super) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let amount_sats = params.get("amount_sats")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
|
||||||
let memo = params.get("memo")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if amount_sats < 0 {
|
|
||||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
|
||||||
}
|
|
||||||
if amount_sats > 21_000_000 * 100_000_000 {
|
|
||||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit memo length to prevent abuse
|
|
||||||
if memo.len() > 639 {
|
|
||||||
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(amount_sats = amount_sats, "Creating Lightning invoice");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let invoice_body = serde_json::json!({
|
|
||||||
"value": amount_sats.to_string(),
|
|
||||||
"memo": memo,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v1/invoices")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&invoice_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to create invoice")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse invoice response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payment_request = body.get("payment_request")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"payment_request": payment_request,
|
|
||||||
"amount_sats": amount_sats,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pay a Lightning invoice.
|
|
||||||
pub(super) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.unwrap_or_default();
|
|
||||||
let payment_request = params.get("payment_request")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
|
||||||
|
|
||||||
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
|
||||||
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
|
||||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
|
||||||
}
|
|
||||||
let lower = payment_request.to_lowercase();
|
|
||||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
|
|
||||||
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Paying Lightning invoice");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let pay_body = serde_json::json!({
|
|
||||||
"payment_request": payment_request,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&pay_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to pay invoice")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse payment response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if !payment_error.is_empty() {
|
|
||||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
|
||||||
}
|
|
||||||
|
|
||||||
let amount_sat = body.get("payment_route")
|
|
||||||
.and_then(|r| r.get("total_amt"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let payment_hash = body.get("payment_hash")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"payment_hash": payment_hash,
|
|
||||||
"amount_sats": amount_sat,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/// Create an unsigned PSBT for hardware wallet signing.
|
|
||||||
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
|
||||||
pub(super) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
|
|
||||||
let outputs = params.get("outputs")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
|
|
||||||
|
|
||||||
if outputs.is_empty() {
|
|
||||||
return Err(anyhow::anyhow!("outputs must not be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the outputs map for LND: { "address": "amount_sats_as_string" }
|
|
||||||
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
|
||||||
let mut total_amount: i64 = 0;
|
|
||||||
for output in outputs {
|
|
||||||
let addr = output.get("address")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
|
||||||
// Validate Bitcoin address format
|
|
||||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
||||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
|
||||||
}
|
|
||||||
let amount = output.get("amount_sats")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
|
||||||
if amount < 546 {
|
|
||||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
|
||||||
}
|
|
||||||
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
|
||||||
total_amount += amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(10);
|
|
||||||
|
|
||||||
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let fund_body = serde_json::json!({
|
|
||||||
"raw": {
|
|
||||||
"outputs": lnd_outputs,
|
|
||||||
},
|
|
||||||
"sat_per_vbyte": sat_per_vbyte,
|
|
||||||
"spend_unconfirmed": false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&fund_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to create PSBT via LND")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse PSBT response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let funded_psbt = body.get("funded_psbt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let change_output_index = body.get("change_output_index")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(-1);
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"psbt_base64": funded_psbt,
|
|
||||||
"change_output_index": change_output_index,
|
|
||||||
"total_amount_sats": total_amount,
|
|
||||||
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finalize a signed PSBT and broadcast the transaction.
|
|
||||||
/// Takes a PSBT that has been signed by a hardware wallet.
|
|
||||||
pub(super) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let signed_psbt = params.get("signed_psbt_base64")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
|
||||||
|
|
||||||
info!("Finalizing signed PSBT from hardware wallet");
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let finalize_body = serde_json::json!({
|
|
||||||
"funded_psbt": signed_psbt,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&finalize_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to finalize PSBT via LND")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse finalize response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_final_tx = body.get("raw_final_tx")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Broadcast the finalized transaction
|
|
||||||
let publish_body = serde_json::json!({
|
|
||||||
"tx_hex": raw_final_tx,
|
|
||||||
});
|
|
||||||
|
|
||||||
let pub_resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&publish_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to broadcast transaction")?;
|
|
||||||
|
|
||||||
let pub_status = pub_resp.status();
|
|
||||||
let pub_body: serde_json::Value = pub_resp.json().await
|
|
||||||
.context("Failed to parse broadcast response")?;
|
|
||||||
|
|
||||||
if !pub_status.is_success() {
|
|
||||||
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"raw_final_tx": raw_final_tx,
|
|
||||||
"broadcast": true,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a signed raw transaction WITHOUT broadcasting.
|
|
||||||
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
|
||||||
/// internet-connected peer who broadcasts it.
|
|
||||||
pub(super) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
|
|
||||||
let addr = params.get("addr")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
|
||||||
let amount_sats = params.get("amount_sats")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
|
||||||
|
|
||||||
if amount_sats < 546 {
|
|
||||||
anyhow::bail!("Amount must be at least 546 sats (dust limit)");
|
|
||||||
}
|
|
||||||
if amount_sats > 2_100_000_000_000_000 {
|
|
||||||
anyhow::bail!("Amount exceeds 21M BTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
// Step 1: Fund a PSBT with the desired output
|
|
||||||
let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5);
|
|
||||||
let fund_body = serde_json::json!({
|
|
||||||
"raw": {
|
|
||||||
"outputs": { addr: amount_sats }
|
|
||||||
},
|
|
||||||
"sat_per_vbyte": fee_rate,
|
|
||||||
"spend_unconfirmed": false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&fund_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to fund PSBT via LND")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse fund response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let funded_psbt = body.get("funded_psbt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
|
||||||
|
|
||||||
// Step 2: Finalize (LND auto-signs with hot wallet keys)
|
|
||||||
let finalize_body = serde_json::json!({
|
|
||||||
"funded_psbt": funded_psbt,
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.json(&finalize_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to finalize PSBT")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp.json().await
|
|
||||||
.context("Failed to parse finalize response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
// raw_final_tx from LND is base64-encoded — decode to hex for Bitcoin RPC
|
|
||||||
let raw_final_tx_b64 = body.get("raw_final_tx")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
|
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
let tx_bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(raw_final_tx_b64)
|
|
||||||
.context("Failed to decode raw_final_tx base64")?;
|
|
||||||
let raw_tx_hex = hex::encode(&tx_bytes);
|
|
||||||
|
|
||||||
info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)");
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"raw_tx_hex": raw_tx_hex,
|
|
||||||
"amount_sats": amount_sats,
|
|
||||||
"addr": addr,
|
|
||||||
"broadcast": false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List on-chain transactions from LND.
|
|
||||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
|
||||||
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://127.0.0.1:8080/v1/transactions")
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("LND REST connection failed")?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse transactions response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown error");
|
|
||||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let empty_vec = vec![];
|
|
||||||
let raw_txs = body
|
|
||||||
.get("transactions")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.unwrap_or(&empty_vec);
|
|
||||||
|
|
||||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
|
||||||
for tx in raw_txs {
|
|
||||||
let amount: i64 = tx
|
|
||||||
.get("amount")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let num_confirmations: i64 = tx
|
|
||||||
.get("num_confirmations")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let tx_hash = tx
|
|
||||||
.get("tx_hash")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let time_stamp: i64 = tx
|
|
||||||
.get("time_stamp")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let total_fees: i64 = tx
|
|
||||||
.get("total_fees")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let dest_addresses: Vec<String> = tx
|
|
||||||
.get("dest_addresses")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|arr| {
|
|
||||||
arr.iter()
|
|
||||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let label = tx
|
|
||||||
.get("label")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let block_height: i64 = tx
|
|
||||||
.get("block_height")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
|
||||||
|
|
||||||
transactions.push(serde_json::json!({
|
|
||||||
"tx_hash": tx_hash,
|
|
||||||
"amount_sats": amount.abs(),
|
|
||||||
"direction": direction,
|
|
||||||
"num_confirmations": num_confirmations,
|
|
||||||
"time_stamp": time_stamp,
|
|
||||||
"total_fees": total_fees,
|
|
||||||
"dest_addresses": dest_addresses,
|
|
||||||
"label": label,
|
|
||||||
"block_height": block_height,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp descending (most recent first)
|
|
||||||
transactions.sort_by(|a, b| {
|
|
||||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
tb.cmp(&ta)
|
|
||||||
});
|
|
||||||
|
|
||||||
let incoming_pending: usize = transactions
|
|
||||||
.iter()
|
|
||||||
.filter(|t| {
|
|
||||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
|
||||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"transactions": transactions,
|
|
||||||
"incoming_pending_count": incoming_pending,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
|
||||||
/// for building lndconnect:// URIs in the frontend.
|
|
||||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
|
||||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
|
||||||
let macaroon_path =
|
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
|
|
||||||
// Read and encode TLS cert (PEM → DER → base64url)
|
|
||||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
|
||||||
.await
|
|
||||||
.context("Failed to read LND TLS certificate")?;
|
|
||||||
let cert_der_b64: String = cert_pem
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.starts_with("-----"))
|
|
||||||
.collect();
|
|
||||||
let cert_der = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(&cert_der_b64)
|
|
||||||
.context("Failed to decode PEM base64")?;
|
|
||||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
|
||||||
|
|
||||||
// Read and encode macaroon (binary → base64url)
|
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
|
||||||
.await
|
|
||||||
.context("Failed to read LND admin macaroon")?;
|
|
||||||
let macaroon_b64url =
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
|
||||||
|
|
||||||
// Read Tor onion address if available
|
|
||||||
let tor_onion = tokio::fs::read_to_string(
|
|
||||||
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string());
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"cert_base64url": cert_b64url,
|
|
||||||
"macaroon_base64url": macaroon_b64url,
|
|
||||||
"tor_onion": tor_onion,
|
|
||||||
"rest_port": 8080,
|
|
||||||
"grpc_port": 10009,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel types
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ChannelInfo {
|
|
||||||
chan_id: String,
|
|
||||||
remote_pubkey: String,
|
|
||||||
capacity: i64,
|
|
||||||
local_balance: i64,
|
|
||||||
remote_balance: i64,
|
|
||||||
active: bool,
|
|
||||||
status: String,
|
|
||||||
channel_point: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ChannelListResult {
|
|
||||||
channels: Vec<ChannelInfo>,
|
|
||||||
total_inbound: i64,
|
|
||||||
total_outbound: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndListChannelsResponse {
|
|
||||||
channels: Option<Vec<LndChannel>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndChannel {
|
|
||||||
chan_id: Option<String>,
|
|
||||||
remote_pubkey: Option<String>,
|
|
||||||
capacity: Option<String>,
|
|
||||||
local_balance: Option<String>,
|
|
||||||
remote_balance: Option<String>,
|
|
||||||
active: Option<bool>,
|
|
||||||
channel_point: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
|
||||||
struct LndPendingChannelsResponse {
|
|
||||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndPendingOpenChannel {
|
|
||||||
channel: Option<LndPendingChannel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LndPendingChannel {
|
|
||||||
remote_node_pub: Option<String>,
|
|
||||||
capacity: Option<String>,
|
|
||||||
local_balance: Option<String>,
|
|
||||||
remote_balance: Option<String>,
|
|
||||||
channel_point: Option<String>,
|
|
||||||
}
|
|
||||||
251
core/archipelago/src/api/rpc/lnd/channels.rs
Normal file
251
core/archipelago/src/api/rpc/lnd/channels.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChannelInfo {
|
||||||
|
chan_id: String,
|
||||||
|
remote_pubkey: String,
|
||||||
|
capacity: i64,
|
||||||
|
local_balance: i64,
|
||||||
|
remote_balance: i64,
|
||||||
|
active: bool,
|
||||||
|
status: String,
|
||||||
|
channel_point: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChannelListResult {
|
||||||
|
channels: Vec<ChannelInfo>,
|
||||||
|
total_inbound: i64,
|
||||||
|
total_outbound: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndListChannelsResponse {
|
||||||
|
channels: Option<Vec<LndChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndChannel {
|
||||||
|
chan_id: Option<String>,
|
||||||
|
remote_pubkey: Option<String>,
|
||||||
|
capacity: Option<String>,
|
||||||
|
local_balance: Option<String>,
|
||||||
|
remote_balance: Option<String>,
|
||||||
|
active: Option<bool>,
|
||||||
|
channel_point: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct LndPendingChannelsResponse {
|
||||||
|
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndPendingOpenChannel {
|
||||||
|
channel: Option<LndPendingChannel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndPendingChannel {
|
||||||
|
remote_node_pub: Option<String>,
|
||||||
|
capacity: Option<String>,
|
||||||
|
local_balance: Option<String>,
|
||||||
|
remote_balance: Option<String>,
|
||||||
|
channel_point: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let channels_resp: LndListChannelsResponse = client
|
||||||
|
.get("https://127.0.0.1:8080/v1/channels")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse LND channels response")?;
|
||||||
|
|
||||||
|
let pending_resp: LndPendingChannelsResponse = match client
|
||||||
|
.get("https://127.0.0.1:8080/v1/channels/pending")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp.json().await.unwrap_or_default(),
|
||||||
|
Err(_) => LndPendingChannelsResponse::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let channels: Vec<ChannelInfo> = channels_resp
|
||||||
|
.channels
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|ch| {
|
||||||
|
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
ChannelInfo {
|
||||||
|
chan_id: ch.chan_id.unwrap_or_default(),
|
||||||
|
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||||
|
capacity,
|
||||||
|
local_balance: local,
|
||||||
|
remote_balance: remote,
|
||||||
|
active: ch.active.unwrap_or(false),
|
||||||
|
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
|
||||||
|
channel_point: ch.channel_point.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||||
|
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||||
|
if let Some(ch) = pch.channel {
|
||||||
|
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
pending_channels.push(ChannelInfo {
|
||||||
|
chan_id: String::new(),
|
||||||
|
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||||
|
capacity,
|
||||||
|
local_balance: local,
|
||||||
|
remote_balance: remote,
|
||||||
|
active: false,
|
||||||
|
status: "pending_open".into(),
|
||||||
|
channel_point: ch.channel_point.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
||||||
|
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
||||||
|
|
||||||
|
let mut all_channels = channels;
|
||||||
|
all_channels.extend(pending_channels);
|
||||||
|
|
||||||
|
let result = ChannelListResult {
|
||||||
|
channels: all_channels,
|
||||||
|
total_inbound: total_remote,
|
||||||
|
total_outbound: total_local,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(result)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let pubkey = params.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||||
|
let amount = params.get("amount")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||||
|
|
||||||
|
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||||
|
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if amount < 20000 {
|
||||||
|
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
|
||||||
|
}
|
||||||
|
if amount > 16_777_215 {
|
||||||
|
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
// First connect to the peer if an address is provided
|
||||||
|
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||||
|
// Validate peer address format (host:port)
|
||||||
|
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||||
|
return Err(anyhow::anyhow!("Invalid peer address format"));
|
||||||
|
}
|
||||||
|
let connect_body = serde_json::json!({
|
||||||
|
"addr": { "pubkey": pubkey, "host": addr },
|
||||||
|
"perm": true
|
||||||
|
});
|
||||||
|
let _ = client
|
||||||
|
.post("https://127.0.0.1:8080/v1/peers")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&connect_body)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open_body = serde_json::json!({
|
||||||
|
"node_pubkey_string": pubkey,
|
||||||
|
"local_funding_amount": amount.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v1/channels")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&open_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to open channel")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let channel_point = params.get("channel_point")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
|
||||||
|
|
||||||
|
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
|
||||||
|
}
|
||||||
|
// Validate txid is 64-char hex and output_index is numeric
|
||||||
|
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
|
||||||
|
}
|
||||||
|
if parts[1].parse::<u32>().is_err() {
|
||||||
|
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
||||||
|
parts[0], parts[1], force
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to close channel")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "success": true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
228
core/archipelago/src/api/rpc/lnd/info.rs
Normal file
228
core/archipelago/src/api/rpc/lnd/info.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{LndAmount, LndBalanceResponse};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LndInfo {
|
||||||
|
alias: String,
|
||||||
|
num_active_channels: u32,
|
||||||
|
num_peers: u32,
|
||||||
|
synced_to_chain: bool,
|
||||||
|
block_height: u64,
|
||||||
|
balance_sats: i64,
|
||||||
|
channel_balance_sats: i64,
|
||||||
|
pending_open_balance: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndGetInfoResponse {
|
||||||
|
alias: Option<String>,
|
||||||
|
num_active_channels: Option<u32>,
|
||||||
|
num_peers: Option<u32>,
|
||||||
|
synced_to_chain: Option<bool>,
|
||||||
|
block_height: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndChannelBalanceResponse {
|
||||||
|
local_balance: Option<LndAmount>,
|
||||||
|
pending_open_local_balance: Option<LndAmount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||||
|
let macaroon_path =
|
||||||
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
|
||||||
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||||
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
let get_info: LndGetInfoResponse = client
|
||||||
|
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse LND getinfo response")?;
|
||||||
|
|
||||||
|
let channel_balance: LndChannelBalanceResponse = match client
|
||||||
|
.get("https://127.0.0.1:8080/v1/balance/channels")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||||
|
local_balance: None,
|
||||||
|
pending_open_local_balance: None,
|
||||||
|
}),
|
||||||
|
Err(_) => LndChannelBalanceResponse {
|
||||||
|
local_balance: None,
|
||||||
|
pending_open_local_balance: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let wallet_balance: LndBalanceResponse = match client
|
||||||
|
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||||
|
total_balance: None,
|
||||||
|
}),
|
||||||
|
Err(_) => LndBalanceResponse {
|
||||||
|
total_balance: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = LndInfo {
|
||||||
|
alias: get_info.alias.unwrap_or_default(),
|
||||||
|
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||||
|
num_peers: get_info.num_peers.unwrap_or(0),
|
||||||
|
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||||
|
block_height: get_info.block_height.unwrap_or(0),
|
||||||
|
balance_sats: wallet_balance
|
||||||
|
.total_balance
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
channel_balance_sats: channel_balance
|
||||||
|
.local_balance
|
||||||
|
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||||
|
.unwrap_or(0),
|
||||||
|
pending_open_balance: channel_balance
|
||||||
|
.pending_open_local_balance
|
||||||
|
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(info)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
||||||
|
/// for building lndconnect:// URIs in the frontend.
|
||||||
|
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||||
|
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||||
|
let macaroon_path =
|
||||||
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
|
||||||
|
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||||
|
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read LND TLS certificate")?;
|
||||||
|
let cert_der_b64: String = cert_pem
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect();
|
||||||
|
let cert_der = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cert_der_b64)
|
||||||
|
.context("Failed to decode PEM base64")?;
|
||||||
|
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||||
|
|
||||||
|
// Read and encode macaroon (binary -> base64url)
|
||||||
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read LND admin macaroon")?;
|
||||||
|
let macaroon_b64url =
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||||
|
|
||||||
|
// Read Tor onion address -- check system Tor path first, then legacy
|
||||||
|
let tor_onion = {
|
||||||
|
let mut onion = None;
|
||||||
|
for path in &[
|
||||||
|
"/var/lib/archipelago/tor-hostnames/lnd",
|
||||||
|
"/var/lib/tor/hidden_service_lnd/hostname",
|
||||||
|
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
||||||
|
] {
|
||||||
|
if let Ok(addr) = tokio::fs::read_to_string(path).await {
|
||||||
|
let addr = addr.trim().to_string();
|
||||||
|
if addr.ends_with(".onion") {
|
||||||
|
onion = Some(addr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try sudo for system Tor dirs (owned by debian-tor, 0700)
|
||||||
|
if let Ok(output) = tokio::process::Command::new("sudo")
|
||||||
|
.args(["cat", path])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let addr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if addr.ends_with(".onion") {
|
||||||
|
onion = Some(addr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onion
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"cert_base64url": cert_b64url,
|
||||||
|
"macaroon_base64url": macaroon_b64url,
|
||||||
|
"tor_onion": tor_onion,
|
||||||
|
"rest_port": 8080,
|
||||||
|
"grpc_port": 10009,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
||||||
|
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(&self) -> Result<serde_json::Value> {
|
||||||
|
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read LND admin macaroon")?;
|
||||||
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.context("Failed to build HTTP client")?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://127.0.0.1:8080/v1/channels/backup")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to reach LND REST API")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("LND returned {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?;
|
||||||
|
|
||||||
|
// Extract the multi_chan_backup bytes
|
||||||
|
let backup_b64 = data
|
||||||
|
.get("multi_chan_backup")
|
||||||
|
.and_then(|m| m.get("multi_chan_backup"))
|
||||||
|
.and_then(|b| b.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"backup": backup_b64,
|
||||||
|
"channel_count": data.get("multi_chan_backup")
|
||||||
|
.and_then(|m| m.get("chan_points"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0),
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
38
core/archipelago/src/api/rpc/lnd/mod.rs
Normal file
38
core/archipelago/src/api/rpc/lnd/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
mod channels;
|
||||||
|
mod info;
|
||||||
|
mod payments;
|
||||||
|
mod wallet;
|
||||||
|
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
// Shared LND response types used by multiple submodules
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub(super) struct LndBalanceResponse {
|
||||||
|
pub total_balance: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub(super) struct LndAmount {
|
||||||
|
pub sat: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Helper: create an authenticated LND REST client.
|
||||||
|
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||||
|
/// hex-encoded admin macaroon for request headers.
|
||||||
|
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||||
|
let macaroon_path =
|
||||||
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||||
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
Ok((client, macaroon_hex))
|
||||||
|
}
|
||||||
|
}
|
||||||
191
core/archipelago/src/api/rpc/lnd/payments.rs
Normal file
191
core/archipelago/src/api/rpc/lnd/payments.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Pay a Lightning invoice.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let payment_request = params.get("payment_request")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||||
|
|
||||||
|
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
||||||
|
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||||
|
}
|
||||||
|
let lower = payment_request.to_lowercase();
|
||||||
|
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
|
||||||
|
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Paying Lightning invoice");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let pay_body = serde_json::json!({
|
||||||
|
"payment_request": payment_request,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&pay_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to pay invoice")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse payment response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if !payment_error.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let amount_sat = body.get("payment_route")
|
||||||
|
.and_then(|r| r.get("total_amt"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let payment_hash = body.get("payment_hash")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
"amount_sats": amount_sat,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List on-chain transactions from LND.
|
||||||
|
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://127.0.0.1:8080/v1/transactions")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse transactions response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let empty_vec = vec![];
|
||||||
|
let raw_txs = body
|
||||||
|
.get("transactions")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.unwrap_or(&empty_vec);
|
||||||
|
|
||||||
|
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for tx in raw_txs {
|
||||||
|
let amount: i64 = tx
|
||||||
|
.get("amount")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let num_confirmations: i64 = tx
|
||||||
|
.get("num_confirmations")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let tx_hash = tx
|
||||||
|
.get("tx_hash")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let time_stamp: i64 = tx
|
||||||
|
.get("time_stamp")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let total_fees: i64 = tx
|
||||||
|
.get("total_fees")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let dest_addresses: Vec<String> = tx
|
||||||
|
.get("dest_addresses")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let label = tx
|
||||||
|
.get("label")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let block_height: i64 = tx
|
||||||
|
.get("block_height")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||||
|
|
||||||
|
transactions.push(serde_json::json!({
|
||||||
|
"tx_hash": tx_hash,
|
||||||
|
"amount_sats": amount.abs(),
|
||||||
|
"direction": direction,
|
||||||
|
"num_confirmations": num_confirmations,
|
||||||
|
"time_stamp": time_stamp,
|
||||||
|
"total_fees": total_fees,
|
||||||
|
"dest_addresses": dest_addresses,
|
||||||
|
"label": label,
|
||||||
|
"block_height": block_height,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending (most recent first)
|
||||||
|
transactions.sort_by(|a, b| {
|
||||||
|
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
tb.cmp(&ta)
|
||||||
|
});
|
||||||
|
|
||||||
|
let incoming_pending: usize = transactions
|
||||||
|
.iter()
|
||||||
|
.filter(|t| {
|
||||||
|
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||||
|
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"transactions": transactions,
|
||||||
|
"incoming_pending_count": incoming_pending,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
384
core/archipelago/src/api/rpc/lnd/wallet.rs
Normal file
384
core/archipelago/src/api/rpc/lnd/wallet.rs
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Generate a new on-chain Bitcoin address.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get("https://127.0.0.1:8080/v1/newaddress")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?;
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse newaddress response")?;
|
||||||
|
|
||||||
|
let address = body.get("address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "address": address }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send on-chain Bitcoin to an address.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let addr = params.get("addr")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||||
|
let amount = params.get("amount")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||||
|
|
||||||
|
if amount < 546 {
|
||||||
|
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||||
|
}
|
||||||
|
if amount > 21_000_000 * 100_000_000 {
|
||||||
|
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Bitcoin address format (basic: length and allowed chars)
|
||||||
|
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let send_body = serde_json::json!({
|
||||||
|
"addr": addr,
|
||||||
|
"amount": amount.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v1/transactions")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&send_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to send on-chain transaction")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse send response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
Ok(serde_json::json!({ "txid": txid }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Lightning invoice.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let amount_sats = params.get("amount_sats")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
||||||
|
let memo = params.get("memo")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if amount_sats < 0 {
|
||||||
|
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||||
|
}
|
||||||
|
if amount_sats > 21_000_000 * 100_000_000 {
|
||||||
|
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit memo length to prevent abuse
|
||||||
|
if memo.len() > 639 {
|
||||||
|
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(amount_sats = amount_sats, "Creating Lightning invoice");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let invoice_body = serde_json::json!({
|
||||||
|
"value": amount_sats.to_string(),
|
||||||
|
"memo": memo,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v1/invoices")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&invoice_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to create invoice")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse invoice response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payment_request = body.get("payment_request")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"payment_request": payment_request,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an unsigned PSBT for hardware wallet signing.
|
||||||
|
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let outputs = params.get("outputs")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
|
||||||
|
|
||||||
|
if outputs.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("outputs must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the outputs map for LND: { "address": "amount_sats_as_string" }
|
||||||
|
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||||
|
let mut total_amount: i64 = 0;
|
||||||
|
for output in outputs {
|
||||||
|
let addr = output.get("address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
||||||
|
// Validate Bitcoin address format
|
||||||
|
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
||||||
|
}
|
||||||
|
let amount = output.get("amount_sats")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
||||||
|
if amount < 546 {
|
||||||
|
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
||||||
|
}
|
||||||
|
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
||||||
|
total_amount += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(10);
|
||||||
|
|
||||||
|
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let fund_body = serde_json::json!({
|
||||||
|
"raw": {
|
||||||
|
"outputs": lnd_outputs,
|
||||||
|
},
|
||||||
|
"sat_per_vbyte": sat_per_vbyte,
|
||||||
|
"spend_unconfirmed": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&fund_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to create PSBT via LND")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse PSBT response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let funded_psbt = body.get("funded_psbt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let change_output_index = body.get("change_output_index")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"psbt_base64": funded_psbt,
|
||||||
|
"change_output_index": change_output_index,
|
||||||
|
"total_amount_sats": total_amount,
|
||||||
|
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize a signed PSBT and broadcast the transaction.
|
||||||
|
/// Takes a PSBT that has been signed by a hardware wallet.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let signed_psbt = params.get("signed_psbt_base64")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
||||||
|
|
||||||
|
info!("Finalizing signed PSBT from hardware wallet");
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let finalize_body = serde_json::json!({
|
||||||
|
"funded_psbt": signed_psbt,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&finalize_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to finalize PSBT via LND")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse finalize response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_final_tx = body.get("raw_final_tx")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Broadcast the finalized transaction
|
||||||
|
let publish_body = serde_json::json!({
|
||||||
|
"tx_hex": raw_final_tx,
|
||||||
|
});
|
||||||
|
|
||||||
|
let pub_resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&publish_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to broadcast transaction")?;
|
||||||
|
|
||||||
|
let pub_status = pub_resp.status();
|
||||||
|
let pub_body: serde_json::Value = pub_resp.json().await
|
||||||
|
.context("Failed to parse broadcast response")?;
|
||||||
|
|
||||||
|
if !pub_status.is_success() {
|
||||||
|
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"raw_final_tx": raw_final_tx,
|
||||||
|
"broadcast": true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a signed raw transaction WITHOUT broadcasting.
|
||||||
|
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
||||||
|
/// internet-connected peer who broadcasts it.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let addr = params.get("addr")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
||||||
|
let amount_sats = params.get("amount_sats")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
||||||
|
|
||||||
|
if amount_sats < 546 {
|
||||||
|
anyhow::bail!("Amount must be at least 546 sats (dust limit)");
|
||||||
|
}
|
||||||
|
if amount_sats > 2_100_000_000_000_000 {
|
||||||
|
anyhow::bail!("Amount exceeds 21M BTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
// Step 1: Fund a PSBT with the desired output
|
||||||
|
let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5);
|
||||||
|
let fund_body = serde_json::json!({
|
||||||
|
"raw": {
|
||||||
|
"outputs": { addr: amount_sats }
|
||||||
|
},
|
||||||
|
"sat_per_vbyte": fee_rate,
|
||||||
|
"spend_unconfirmed": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&fund_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to fund PSBT via LND")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse fund response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let funded_psbt = body.get("funded_psbt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
||||||
|
|
||||||
|
// Step 2: Finalize (LND auto-signs with hot wallet keys)
|
||||||
|
let finalize_body = serde_json::json!({
|
||||||
|
"funded_psbt": funded_psbt,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&finalize_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to finalize PSBT")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.context("Failed to parse finalize response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw_final_tx from LND is base64-encoded -- decode to hex for Bitcoin RPC
|
||||||
|
let raw_final_tx_b64 = body.get("raw_final_tx")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
|
||||||
|
|
||||||
|
let tx_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(raw_final_tx_b64)
|
||||||
|
.context("Failed to decode raw_final_tx base64")?;
|
||||||
|
let raw_tx_hex = hex::encode(&tx_bytes);
|
||||||
|
|
||||||
|
info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"raw_tx_hex": raw_tx_hex,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
"addr": addr,
|
||||||
|
"broadcast": false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,10 +179,29 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing r_hash"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing r_hash"))?;
|
||||||
|
|
||||||
// Check invoice status — stub until LND lookup is implemented
|
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
|
||||||
// TODO: Add lnd.lookupinvoice RPC endpoint for real payment verification
|
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
let paid = false; // Payment verification pending LND integration
|
return Err(anyhow::anyhow!("Invalid r_hash: must be 64-character hex string"));
|
||||||
let _ = r_hash; // Used when LND lookup is available
|
}
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://127.0.0.1:8080/v1/invoice/{}",
|
||||||
|
r_hash
|
||||||
|
);
|
||||||
|
let paid = match client
|
||||||
|
.get(&url)
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||||
|
body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"r_hash": r_hash,
|
"r_hash": r_hash,
|
||||||
|
|||||||
@@ -1,865 +0,0 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use crate::mesh;
|
|
||||||
use crate::mesh::message_types::{
|
|
||||||
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
impl RpcHandler {
|
|
||||||
/// mesh.status — Get mesh radio status, device info, and peer count.
|
|
||||||
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
if let Some(svc) = service.as_ref() {
|
|
||||||
let status = svc.status().await;
|
|
||||||
Ok(serde_json::to_value(status)?)
|
|
||||||
} else {
|
|
||||||
// No service running — return basic config + device detection
|
|
||||||
let config = mesh::load_config(&self.config.data_dir).await?;
|
|
||||||
let devices = mesh::detect_devices().await;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"enabled": config.enabled,
|
|
||||||
"device_connected": false,
|
|
||||||
"device_type": "unknown",
|
|
||||||
"device_path": config.device_path,
|
|
||||||
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
|
||||||
"detected_devices": devices,
|
|
||||||
"peer_count": 0,
|
|
||||||
"messages_sent": 0,
|
|
||||||
"messages_received": 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.peers — List discovered mesh peers.
|
|
||||||
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
if let Some(svc) = service.as_ref() {
|
|
||||||
let peers = svc.peers().await;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"peers": peers,
|
|
||||||
"count": peers.len(),
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"peers": [],
|
|
||||||
"count": 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.messages — Get recent mesh message history.
|
|
||||||
pub(super) async fn handle_mesh_messages(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let limit = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("limit"))
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map(|n| n as usize);
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
if let Some(svc) = service.as_ref() {
|
|
||||||
let messages = svc.messages(limit).await;
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"messages": messages,
|
|
||||||
"count": messages.len(),
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"messages": [],
|
|
||||||
"count": 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.send — Send an encrypted message to a mesh peer.
|
|
||||||
pub(super) async fn handle_mesh_send(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
|
|
||||||
let contact_id = params
|
|
||||||
.get("contact_id")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
|
||||||
|
|
||||||
let message = params
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
|
||||||
|
|
||||||
if message.is_empty() {
|
|
||||||
anyhow::bail!("Message cannot be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
|
||||||
|
|
||||||
let msg = svc.send_message(contact_id, message).await?;
|
|
||||||
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"sent": true,
|
|
||||||
"message_id": msg.id,
|
|
||||||
"encrypted": msg.encrypted,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
|
||||||
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
|
||||||
|
|
||||||
svc.broadcast_identity().await?;
|
|
||||||
info!("Broadcast identity over mesh");
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "broadcast": true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.configure — Enable/disable mesh and set device path.
|
|
||||||
pub(super) async fn handle_mesh_configure(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
|
|
||||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
|
||||||
|
|
||||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
|
||||||
config.enabled = enabled;
|
|
||||||
}
|
|
||||||
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
|
||||||
config.device_path = Some(device.to_string());
|
|
||||||
}
|
|
||||||
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
|
||||||
config.channel_name = Some(channel.to_string());
|
|
||||||
}
|
|
||||||
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
|
||||||
config.broadcast_identity = broadcast;
|
|
||||||
}
|
|
||||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
|
||||||
config.advert_name = Some(name.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
|
||||||
|
|
||||||
// If we have a running service, update its config
|
|
||||||
let mut service = self.mesh_service.write().await;
|
|
||||||
if let Some(svc) = service.as_mut() {
|
|
||||||
svc.configure(config.clone()).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Mesh config updated");
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"configured": true,
|
|
||||||
"enabled": config.enabled,
|
|
||||||
"device_path": config.device_path,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 3: Typed Messages ────────────────────────────────────────
|
|
||||||
|
|
||||||
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
|
||||||
pub(super) async fn handle_mesh_send_invoice(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let contact_id = params["contact_id"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
|
||||||
let amount_sats = params["amount_sats"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
|
||||||
let memo = params["memo"].as_str().map(|s| s.to_string());
|
|
||||||
|
|
||||||
// Build invoice payload
|
|
||||||
let invoice = InvoicePayload {
|
|
||||||
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
|
||||||
amount_sats,
|
|
||||||
memo: memo.clone(),
|
|
||||||
payment_hash: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = message_types::encode_payload(&invoice)?;
|
|
||||||
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
|
||||||
let wire = envelope.to_wire()?;
|
|
||||||
|
|
||||||
// Send via mesh
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
|
||||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
|
||||||
|
|
||||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"sent": true,
|
|
||||||
"message_id": msg.id,
|
|
||||||
"amount_sats": amount_sats,
|
|
||||||
"bolt11": invoice.bolt11,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
|
||||||
pub(super) async fn handle_mesh_send_coordinate(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let contact_id = params["contact_id"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
|
||||||
let lat = params["lat"]
|
|
||||||
.as_f64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
|
||||||
let lng = params["lng"]
|
|
||||||
.as_f64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
|
||||||
let label = params["label"].as_str().map(|s| s.to_string());
|
|
||||||
|
|
||||||
let coord = Coordinate::from_degrees(lat, lng, label);
|
|
||||||
let payload = message_types::encode_payload(&coord)?;
|
|
||||||
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
|
||||||
let wire = envelope.to_wire()?;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
|
||||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
|
||||||
|
|
||||||
info!(contact_id, "Sent coordinate over mesh");
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"sent": true,
|
|
||||||
"message_id": msg.id,
|
|
||||||
"lat": coord.lat,
|
|
||||||
"lng": coord.lng,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
|
||||||
pub(super) async fn handle_mesh_send_alert(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let message = params["message"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
|
||||||
let alert_type_str = params["alert_type"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("status");
|
|
||||||
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
|
||||||
|
|
||||||
let alert_type = match alert_type_str {
|
|
||||||
"emergency" => AlertType::Emergency,
|
|
||||||
"dead_man" => AlertType::DeadMan,
|
|
||||||
_ => AlertType::Status,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional GPS
|
|
||||||
let coordinate = if let (Some(lat), Some(lng)) = (
|
|
||||||
params["lat"].as_f64(),
|
|
||||||
params["lng"].as_f64(),
|
|
||||||
) {
|
|
||||||
Some(Coordinate::from_degrees(lat, lng, None))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let alert = AlertPayload {
|
|
||||||
alert_type,
|
|
||||||
message: message.to_string(),
|
|
||||||
coordinate,
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = message_types::encode_payload(&alert)?;
|
|
||||||
|
|
||||||
// Sign the alert with node identity
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
|
||||||
let node_key_path = identity_dir.join("node_key");
|
|
||||||
|
|
||||||
let envelope = if node_key_path.exists() {
|
|
||||||
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
|
||||||
if key_bytes.len() == 32 {
|
|
||||||
let mut seed = [0u8; 32];
|
|
||||||
seed.copy_from_slice(&key_bytes);
|
|
||||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
|
||||||
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
|
||||||
} else {
|
|
||||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
|
||||||
};
|
|
||||||
|
|
||||||
let wire = envelope.to_wire()?;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
|
||||||
if broadcast {
|
|
||||||
// Send on channel (all peers)
|
|
||||||
svc.send_message(0, &wire_str).await?;
|
|
||||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
|
||||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
|
||||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
|
||||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"sent": true,
|
|
||||||
"alert_type": alert_type_str,
|
|
||||||
"signed": envelope.sig.is_some(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.outbox — List pending store-and-forward messages.
|
|
||||||
pub(super) async fn handle_mesh_outbox(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let limit = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p["limit"].as_u64())
|
|
||||||
.map(|n| n as usize);
|
|
||||||
|
|
||||||
// Check if outbox file exists
|
|
||||||
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
|
||||||
let messages = outbox.list(limit).await;
|
|
||||||
let count = outbox.count().await;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"messages": messages.iter().map(|m| serde_json::json!({
|
|
||||||
"id": m.id,
|
|
||||||
"dest_did": m.dest_did,
|
|
||||||
"from_did": m.from_did,
|
|
||||||
"created_at": m.created_at,
|
|
||||||
"ttl_secs": m.ttl_secs,
|
|
||||||
"retry_count": m.retry_count,
|
|
||||||
"relay_hops": m.relay_hops,
|
|
||||||
"expired": m.is_expired(),
|
|
||||||
})).collect::<Vec<_>>(),
|
|
||||||
"count": count,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.session-status — Get ratchet session info for a peer.
|
|
||||||
pub(super) async fn handle_mesh_session_status(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let contact_id = params["contact_id"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
|
||||||
|
|
||||||
// Look up peer DID from mesh service
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let peer_did = if let Some(svc) = service.as_ref() {
|
|
||||||
let peers = svc.peers().await;
|
|
||||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(did) = peer_did {
|
|
||||||
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
|
||||||
if let Some(info) = session_mgr.session_info(&did).await {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"has_session": info.has_session,
|
|
||||||
"forward_secrecy": info.forward_secrecy,
|
|
||||||
"message_count": info.message_count,
|
|
||||||
"ratchet_generation": info.ratchet_generation,
|
|
||||||
"peer_did": did,
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"has_session": false,
|
|
||||||
"forward_secrecy": false,
|
|
||||||
"message_count": 0,
|
|
||||||
"ratchet_generation": 0,
|
|
||||||
"peer_did": did,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"has_session": false,
|
|
||||||
"forward_secrecy": false,
|
|
||||||
"message_count": 0,
|
|
||||||
"ratchet_generation": 0,
|
|
||||||
"peer_did": null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
|
|
||||||
|
|
||||||
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
|
||||||
pub(super) async fn handle_mesh_relay_tx(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let tx_hex = params["tx_hex"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
|
||||||
|
|
||||||
let relay_mode = params["relay_mode"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("archy");
|
|
||||||
|
|
||||||
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
|
||||||
anyhow::bail!("Invalid tx_hex length");
|
|
||||||
}
|
|
||||||
// Validate hex
|
|
||||||
if hex::decode(tx_hex).is_err() {
|
|
||||||
anyhow::bail!("tx_hex is not valid hexadecimal");
|
|
||||||
}
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
|
||||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
|
||||||
|
|
||||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
|
||||||
|
|
||||||
let mut sent_count = 0u32;
|
|
||||||
|
|
||||||
if relay_mode == "broadcast" {
|
|
||||||
// Broadcast mode: send on channel 0 (all mesh nodes relay)
|
|
||||||
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
|
|
||||||
let shared_state = svc.shared_state();
|
|
||||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
|
||||||
|
|
||||||
// Encrypt with first available Archy peer's shared secret
|
|
||||||
// (any Archy node that receives it can try decrypting)
|
|
||||||
let payload = shared_secrets.values().next()
|
|
||||||
.and_then(|secret| {
|
|
||||||
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
|
||||||
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
|
||||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
|
||||||
encrypted.extend_from_slice(&ct);
|
|
||||||
encrypted
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| wire.clone());
|
|
||||||
drop(shared_secrets);
|
|
||||||
|
|
||||||
{
|
|
||||||
use base64::Engine;
|
|
||||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
|
||||||
let _ = shared_state
|
|
||||||
.cmd_tx
|
|
||||||
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
|
||||||
channel: 0,
|
|
||||||
payload: b64.into_bytes(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
sent_count = 1;
|
|
||||||
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
|
|
||||||
} else {
|
|
||||||
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
|
||||||
let peers = svc.peers().await;
|
|
||||||
let shared_state = svc.shared_state();
|
|
||||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
|
||||||
for peer in &peers {
|
|
||||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
|
||||||
if pk_bytes.len() >= 6 {
|
|
||||||
let mut prefix = [0u8; 6];
|
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
|
||||||
|
|
||||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
|
||||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
|
||||||
Ok(ciphertext) => {
|
|
||||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
|
||||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
|
||||||
encrypted.extend_from_slice(&ciphertext);
|
|
||||||
encrypted
|
|
||||||
}
|
|
||||||
Err(_) => wire.clone(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wire.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = svc.shared_state()
|
|
||||||
.cmd_tx
|
|
||||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
|
||||||
dest_pubkey_prefix: prefix,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
sent_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(shared_secrets);
|
|
||||||
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
|
|
||||||
}
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"request_id": request_id,
|
|
||||||
"queued": true,
|
|
||||||
"tx_hex_len": tx_hex.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
|
||||||
pub(super) async fn handle_mesh_relay_status(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let request_id = params["request_id"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
// Check completed results first
|
|
||||||
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
|
|
||||||
"request_id": result.request_id,
|
|
||||||
"txid": result.txid,
|
|
||||||
"error": result.error,
|
|
||||||
"error_code": result.error_code,
|
|
||||||
"completed_at": result.completed_at,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if still pending
|
|
||||||
if svc.relay_tracker.is_pending(request_id).await {
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"status": "pending",
|
|
||||||
"request_id": request_id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown — either expired or never existed
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"status": "unknown",
|
|
||||||
"request_id": request_id,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.block-headers — Get cached block headers received from mesh peers.
|
|
||||||
pub(super) async fn handle_mesh_block_headers(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let count = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p["count"].as_u64())
|
|
||||||
.unwrap_or(10) as usize;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
|
||||||
let latest = svc.block_header_cache.latest_height().await;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"headers": headers.iter().map(|h| serde_json::json!({
|
|
||||||
"height": h.height,
|
|
||||||
"hash": h.hash,
|
|
||||||
"prev_hash": h.prev_hash,
|
|
||||||
"timestamp": h.timestamp,
|
|
||||||
"announced_by": h.announced_by,
|
|
||||||
})).collect::<Vec<_>>(),
|
|
||||||
"latest_height": latest,
|
|
||||||
"count": headers.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
|
||||||
pub(super) async fn handle_mesh_relay_lightning(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let bolt11 = params["bolt11"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
|
||||||
let amount_sats = params["amount_sats"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
|
||||||
|
|
||||||
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
|
||||||
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
|
||||||
}
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
|
||||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
|
||||||
|
|
||||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
|
||||||
bolt11, amount_sats, request_id,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Send to Archipelago peers — E2E encrypted per-peer
|
|
||||||
let peers = svc.peers().await;
|
|
||||||
let shared_state = svc.shared_state();
|
|
||||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
|
||||||
let mut sent_count = 0u32;
|
|
||||||
for peer in &peers {
|
|
||||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
|
||||||
if pk_bytes.len() >= 6 {
|
|
||||||
let mut prefix = [0u8; 6];
|
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
|
||||||
|
|
||||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
|
||||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
|
||||||
Ok(ciphertext) => {
|
|
||||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
|
||||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
|
||||||
encrypted.extend_from_slice(&ciphertext);
|
|
||||||
encrypted
|
|
||||||
}
|
|
||||||
Err(_) => wire.clone(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wire.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = svc.shared_state()
|
|
||||||
.cmd_tx
|
|
||||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
|
||||||
dest_pubkey_prefix: prefix,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
sent_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(shared_secrets);
|
|
||||||
|
|
||||||
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"request_id": request_id,
|
|
||||||
"queued": true,
|
|
||||||
"amount_sats": amount_sats,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.deadman-status — Get dead man's switch status.
|
|
||||||
pub(super) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let status = svc.dead_man_switch.status().await;
|
|
||||||
Ok(serde_json::to_value(status)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.deadman-configure — Configure the dead man's switch.
|
|
||||||
pub(super) async fn handle_mesh_deadman_configure(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let mut config = svc.dead_man_switch.get_config().await;
|
|
||||||
|
|
||||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
|
||||||
config.dead_man_enabled = enabled;
|
|
||||||
}
|
|
||||||
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
|
||||||
if interval < 60 {
|
|
||||||
anyhow::bail!("Interval must be at least 60 seconds");
|
|
||||||
}
|
|
||||||
config.dead_man_interval_secs = interval;
|
|
||||||
}
|
|
||||||
if let (Some(lat), Some(lng)) = (
|
|
||||||
params.get("lat").and_then(|v| v.as_f64()),
|
|
||||||
params.get("lng").and_then(|v| v.as_f64()),
|
|
||||||
) {
|
|
||||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
||||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
|
||||||
}
|
|
||||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
|
||||||
config.emergency_contacts = contacts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
|
||||||
config.custom_message = Some(msg.to_string());
|
|
||||||
}
|
|
||||||
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
|
||||||
config.auto_include_gps = auto_gps;
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.dead_man_switch.configure(config).await?;
|
|
||||||
// Reset timer on configure
|
|
||||||
svc.dead_man_switch.check_in().await;
|
|
||||||
|
|
||||||
let status = svc.dead_man_switch.status().await;
|
|
||||||
info!("Dead man's switch configured");
|
|
||||||
Ok(serde_json::to_value(status)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
|
||||||
pub(super) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
svc.dead_man_check_in().await;
|
|
||||||
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"checked_in": true,
|
|
||||||
"time_remaining_secs": remaining,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
|
||||||
pub(super) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
|
||||||
// Load identity signing key
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
|
||||||
let node_key_path = identity_dir.join("node_key");
|
|
||||||
let key_bytes = tokio::fs::read(&node_key_path)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
|
||||||
if key_bytes.len() != 32 {
|
|
||||||
anyhow::bail!("Invalid node key");
|
|
||||||
}
|
|
||||||
let mut seed = [0u8; 32];
|
|
||||||
seed.copy_from_slice(&key_bytes);
|
|
||||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
|
||||||
|
|
||||||
// Generate new prekey bundle
|
|
||||||
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
|
||||||
|
|
||||||
// Save bundle for distribution
|
|
||||||
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
|
||||||
let prekey_dir = self.config.data_dir.join("prekeys");
|
|
||||||
tokio::fs::create_dir_all(&prekey_dir).await?;
|
|
||||||
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
one_time_keys = bundle.one_time_prekeys.len(),
|
|
||||||
"Prekey bundle rotated"
|
|
||||||
);
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"rotated": true,
|
|
||||||
"signed_prekey_id": bundle.signed_prekey.id,
|
|
||||||
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Radio Diagnostics ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
|
||||||
/// Sends plain text markers that the receiver can count.
|
|
||||||
pub(super) async fn handle_mesh_test_send(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let contact_id = params["contact_id"]
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
|
||||||
|
|
||||||
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
|
|
||||||
let mode = params["mode"].as_str().unwrap_or("ping");
|
|
||||||
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
|
||||||
let svc = service.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
|
||||||
|
|
||||||
let mut sent = 0usize;
|
|
||||||
let test_id = chrono::Utc::now().timestamp() as u32;
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
let payload = match mode {
|
|
||||||
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
|
|
||||||
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
|
|
||||||
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
|
|
||||||
"chunked" => {
|
|
||||||
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
|
||||||
let fake_tx = "0".repeat(400); // simulates TX hex
|
|
||||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
|
|
||||||
// Send via SendRaw which handles base64 + chunking
|
|
||||||
let peers = svc.peers().await;
|
|
||||||
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
|
||||||
if pk_bytes.len() >= 6 {
|
|
||||||
let mut prefix = [0u8; 6];
|
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
|
||||||
let _ = svc.shared_state().cmd_tx.send(
|
|
||||||
crate::mesh::listener::MeshCommand::SendRaw {
|
|
||||||
dest_pubkey_prefix: prefix,
|
|
||||||
payload: wire,
|
|
||||||
},
|
|
||||||
).await;
|
|
||||||
sent += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Delay between chunked sends
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send as plain text for ping/medium/large
|
|
||||||
let msg = svc.send_message(contact_id, &payload).await?;
|
|
||||||
sent += 1;
|
|
||||||
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
|
|
||||||
|
|
||||||
// Small delay between sends
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"test_id": test_id,
|
|
||||||
"mode": mode,
|
|
||||||
"sent": sent,
|
|
||||||
"count": count,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
267
core/archipelago/src/api/rpc/mesh/bitcoin_ops.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_relay_tx(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let tx_hex = params["tx_hex"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
||||||
|
|
||||||
|
let relay_mode = params["relay_mode"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("archy");
|
||||||
|
|
||||||
|
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
||||||
|
anyhow::bail!("Invalid tx_hex length");
|
||||||
|
}
|
||||||
|
// Validate hex
|
||||||
|
if hex::decode(tx_hex).is_err() {
|
||||||
|
anyhow::bail!("tx_hex is not valid hexadecimal");
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||||
|
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
||||||
|
|
||||||
|
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||||
|
|
||||||
|
let mut sent_count = 0u32;
|
||||||
|
|
||||||
|
if relay_mode == "broadcast" {
|
||||||
|
// Broadcast mode: send on channel 0 (all mesh nodes relay)
|
||||||
|
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
|
||||||
|
let shared_state = svc.shared_state();
|
||||||
|
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||||
|
|
||||||
|
// Encrypt with first available Archy peer's shared secret
|
||||||
|
// (any Archy node that receives it can try decrypting)
|
||||||
|
let payload = shared_secrets.values().next()
|
||||||
|
.and_then(|secret| {
|
||||||
|
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
||||||
|
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
||||||
|
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||||
|
encrypted.extend_from_slice(&ct);
|
||||||
|
encrypted
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| wire.clone());
|
||||||
|
drop(shared_secrets);
|
||||||
|
|
||||||
|
{
|
||||||
|
use base64::Engine;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||||
|
let _ = shared_state
|
||||||
|
.cmd_tx
|
||||||
|
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
||||||
|
channel: 0,
|
||||||
|
payload: b64.into_bytes(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
|
||||||
|
} else {
|
||||||
|
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
let shared_state = svc.shared_state();
|
||||||
|
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||||
|
for peer in &peers {
|
||||||
|
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||||
|
if let Some(ref pk) = peer.pubkey_hex {
|
||||||
|
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||||
|
if pk_bytes.len() >= 6 {
|
||||||
|
let mut prefix = [0u8; 6];
|
||||||
|
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||||
|
|
||||||
|
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||||
|
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||||
|
Ok(ciphertext) => {
|
||||||
|
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||||
|
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||||
|
encrypted.extend_from_slice(&ciphertext);
|
||||||
|
encrypted
|
||||||
|
}
|
||||||
|
Err(_) => wire.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wire.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = svc.shared_state()
|
||||||
|
.cmd_tx
|
||||||
|
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
|
dest_pubkey_prefix: prefix,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
sent_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(shared_secrets);
|
||||||
|
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"request_id": request_id,
|
||||||
|
"queued": true,
|
||||||
|
"tx_hex_len": tx_hex.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.relay-status — Check the status of a pending or completed TX relay.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_relay_status(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let request_id = params["request_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
// Check completed results first
|
||||||
|
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
|
||||||
|
"request_id": result.request_id,
|
||||||
|
"txid": result.txid,
|
||||||
|
"error": result.error,
|
||||||
|
"error_code": result.error_code,
|
||||||
|
"completed_at": result.completed_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still pending
|
||||||
|
if svc.relay_tracker.is_pending(request_id).await {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": "pending",
|
||||||
|
"request_id": request_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — either expired or never existed
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": "unknown",
|
||||||
|
"request_id": request_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.block-headers — Get cached block headers received from mesh peers.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_block_headers(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let count = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p["count"].as_u64())
|
||||||
|
.unwrap_or(10) as usize;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||||
|
let latest = svc.block_header_cache.latest_height().await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"headers": headers.iter().map(|h| serde_json::json!({
|
||||||
|
"height": h.height,
|
||||||
|
"hash": h.hash,
|
||||||
|
"prev_hash": h.prev_hash,
|
||||||
|
"timestamp": h.timestamp,
|
||||||
|
"announced_by": h.announced_by,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"latest_height": latest,
|
||||||
|
"count": headers.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_relay_lightning(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let bolt11 = params["bolt11"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||||
|
let amount_sats = params["amount_sats"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||||
|
|
||||||
|
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
|
||||||
|
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||||
|
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
||||||
|
|
||||||
|
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||||
|
bolt11, amount_sats, request_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Send to Archipelago peers — E2E encrypted per-peer
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
let shared_state = svc.shared_state();
|
||||||
|
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||||
|
let mut sent_count = 0u32;
|
||||||
|
for peer in &peers {
|
||||||
|
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||||
|
if let Some(ref pk) = peer.pubkey_hex {
|
||||||
|
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||||
|
if pk_bytes.len() >= 6 {
|
||||||
|
let mut prefix = [0u8; 6];
|
||||||
|
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||||
|
|
||||||
|
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
||||||
|
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||||
|
Ok(ciphertext) => {
|
||||||
|
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||||
|
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||||
|
encrypted.extend_from_slice(&ciphertext);
|
||||||
|
encrypted
|
||||||
|
}
|
||||||
|
Err(_) => wire.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wire.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = svc.shared_state()
|
||||||
|
.cmd_tx
|
||||||
|
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
|
dest_pubkey_prefix: prefix,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
sent_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(shared_secrets);
|
||||||
|
|
||||||
|
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"request_id": request_id,
|
||||||
|
"queued": true,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
96
core/archipelago/src/api/rpc/mesh/messaging.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use crate::mesh;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.send — Send an encrypted message to a mesh peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_send(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let contact_id = params
|
||||||
|
.get("contact_id")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||||
|
|
||||||
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
|
||||||
|
if message.is_empty() {
|
||||||
|
anyhow::bail!("Message cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||||
|
|
||||||
|
let msg = svc.send_message(contact_id, message).await?;
|
||||||
|
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"message_id": msg.id,
|
||||||
|
"encrypted": msg.encrypted,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||||
|
|
||||||
|
svc.broadcast_identity().await?;
|
||||||
|
info!("Broadcast identity over mesh");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "broadcast": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.configure — Enable/disable mesh and set device path.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||||
|
config.enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(device) = params.get("device_path").and_then(|v| v.as_str()) {
|
||||||
|
config.device_path = Some(device.to_string());
|
||||||
|
}
|
||||||
|
if let Some(channel) = params.get("channel_name").and_then(|v| v.as_str()) {
|
||||||
|
config.channel_name = Some(channel.to_string());
|
||||||
|
}
|
||||||
|
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
|
||||||
|
config.broadcast_identity = broadcast;
|
||||||
|
}
|
||||||
|
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||||
|
config.advert_name = Some(name.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
|
// If we have a running service, update its config
|
||||||
|
let mut service = self.mesh_service.write().await;
|
||||||
|
if let Some(svc) = service.as_mut() {
|
||||||
|
svc.configure(config.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Mesh config updated");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"configured": true,
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"device_path": config.device_path,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
5
core/archipelago/src/api/rpc/mesh/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod bitcoin_ops;
|
||||||
|
mod messaging;
|
||||||
|
mod safety;
|
||||||
|
mod status;
|
||||||
|
mod typed_messages;
|
||||||
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
222
core/archipelago/src/api/rpc/mesh/safety.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use crate::mesh;
|
||||||
|
use crate::mesh::message_types::Coordinate;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.outbox — List pending store-and-forward messages.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_outbox(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let limit = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p["limit"].as_u64())
|
||||||
|
.map(|n| n as usize);
|
||||||
|
|
||||||
|
// Check if outbox file exists
|
||||||
|
let outbox = mesh::outbox::MeshOutbox::load(&self.config.data_dir).await?;
|
||||||
|
let messages = outbox.list(limit).await;
|
||||||
|
let count = outbox.count().await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"messages": messages.iter().map(|m| serde_json::json!({
|
||||||
|
"id": m.id,
|
||||||
|
"dest_did": m.dest_did,
|
||||||
|
"from_did": m.from_did,
|
||||||
|
"created_at": m.created_at,
|
||||||
|
"ttl_secs": m.ttl_secs,
|
||||||
|
"retry_count": m.retry_count,
|
||||||
|
"relay_hops": m.relay_hops,
|
||||||
|
"expired": m.is_expired(),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"count": count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.deadman-status — Get dead man's switch status.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let status = svc.dead_man_switch.status().await;
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.deadman-configure — Configure the dead man's switch.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_deadman_configure(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let mut config = svc.dead_man_switch.get_config().await;
|
||||||
|
|
||||||
|
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||||
|
config.dead_man_enabled = enabled;
|
||||||
|
}
|
||||||
|
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
|
||||||
|
if interval < 60 {
|
||||||
|
anyhow::bail!("Interval must be at least 60 seconds");
|
||||||
|
}
|
||||||
|
config.dead_man_interval_secs = interval;
|
||||||
|
}
|
||||||
|
if let (Some(lat), Some(lng)) = (
|
||||||
|
params.get("lat").and_then(|v| v.as_f64()),
|
||||||
|
params.get("lng").and_then(|v| v.as_f64()),
|
||||||
|
) {
|
||||||
|
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||||
|
}
|
||||||
|
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||||
|
config.emergency_contacts = contacts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| c.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
|
||||||
|
config.custom_message = Some(msg.to_string());
|
||||||
|
}
|
||||||
|
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
|
||||||
|
config.auto_include_gps = auto_gps;
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.dead_man_switch.configure(config).await?;
|
||||||
|
// Reset timer on configure
|
||||||
|
svc.dead_man_switch.check_in().await;
|
||||||
|
|
||||||
|
let status = svc.dead_man_switch.status().await;
|
||||||
|
info!("Dead man's switch configured");
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
svc.dead_man_check_in().await;
|
||||||
|
let remaining = svc.dead_man_switch.time_remaining_secs().await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"checked_in": true,
|
||||||
|
"time_remaining_secs": remaining,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
||||||
|
// Load identity signing key
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let node_key_path = identity_dir.join("node_key");
|
||||||
|
let key_bytes = tokio::fs::read(&node_key_path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Node identity not found"))?;
|
||||||
|
if key_bytes.len() != 32 {
|
||||||
|
anyhow::bail!("Invalid node key");
|
||||||
|
}
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
seed.copy_from_slice(&key_bytes);
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||||
|
|
||||||
|
// Generate new prekey bundle
|
||||||
|
let (bundle, _secrets) = mesh::x3dh::generate_prekey_bundle(&signing_key, 10)?;
|
||||||
|
|
||||||
|
// Save bundle for distribution
|
||||||
|
let bundle_bytes = mesh::x3dh::encode_bundle(&bundle)?;
|
||||||
|
let prekey_dir = self.config.data_dir.join("prekeys");
|
||||||
|
tokio::fs::create_dir_all(&prekey_dir).await?;
|
||||||
|
tokio::fs::write(prekey_dir.join("bundle.cbor"), &bundle_bytes).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
one_time_keys = bundle.one_time_prekeys.len(),
|
||||||
|
"Prekey bundle rotated"
|
||||||
|
);
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"rotated": true,
|
||||||
|
"signed_prekey_id": bundle.signed_prekey.id,
|
||||||
|
"one_time_prekeys": bundle.one_time_prekeys.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
|
||||||
|
/// Sends plain text markers that the receiver can count.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_test_send(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let contact_id = params["contact_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||||
|
|
||||||
|
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
|
||||||
|
let mode = params["mode"].as_str().unwrap_or("ping");
|
||||||
|
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let mut sent = 0usize;
|
||||||
|
let test_id = chrono::Utc::now().timestamp() as u32;
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
let payload = match mode {
|
||||||
|
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
|
||||||
|
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
|
||||||
|
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
|
||||||
|
"chunked" => {
|
||||||
|
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
||||||
|
let fake_tx = "0".repeat(400); // simulates TX hex
|
||||||
|
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
|
||||||
|
// Send via SendRaw which handles base64 + chunking
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
||||||
|
if let Some(ref pk) = peer.pubkey_hex {
|
||||||
|
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||||
|
if pk_bytes.len() >= 6 {
|
||||||
|
let mut prefix = [0u8; 6];
|
||||||
|
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||||
|
let _ = svc.shared_state().cmd_tx.send(
|
||||||
|
crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
|
dest_pubkey_prefix: prefix,
|
||||||
|
payload: wire,
|
||||||
|
},
|
||||||
|
).await;
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delay between chunked sends
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send as plain text for ping/medium/large
|
||||||
|
let _msg = svc.send_message(contact_id, &payload).await?;
|
||||||
|
sent += 1;
|
||||||
|
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
|
||||||
|
|
||||||
|
// Small delay between sends
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"test_id": test_id,
|
||||||
|
"mode": mode,
|
||||||
|
"sent": sent,
|
||||||
|
"count": count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
121
core/archipelago/src/api/rpc/mesh/status.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use crate::mesh;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.status — Get mesh radio status, device info, and peer count.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
let status = svc.status().await;
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
} else {
|
||||||
|
// No service running — return basic config + device detection
|
||||||
|
let config = mesh::load_config(&self.config.data_dir).await?;
|
||||||
|
let devices = mesh::detect_devices().await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"device_connected": false,
|
||||||
|
"device_type": "unknown",
|
||||||
|
"device_path": config.device_path,
|
||||||
|
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
|
||||||
|
"detected_devices": devices,
|
||||||
|
"peer_count": 0,
|
||||||
|
"messages_sent": 0,
|
||||||
|
"messages_received": 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.peers — List discovered mesh peers.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"peers": peers,
|
||||||
|
"count": peers.len(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"peers": [],
|
||||||
|
"count": 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.messages — Get recent mesh message history.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_messages(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let limit = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("limit"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|n| n as usize);
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
let messages = svc.messages(limit).await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"messages": messages,
|
||||||
|
"count": messages.len(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"messages": [],
|
||||||
|
"count": 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.session-status — Get ratchet session info for a peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let contact_id = params["contact_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||||
|
|
||||||
|
// Look up peer DID from mesh service
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let peer_did = if let Some(svc) = service.as_ref() {
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(did) = peer_did {
|
||||||
|
let session_mgr = mesh::session::SessionManager::new(&self.config.data_dir);
|
||||||
|
if let Some(info) = session_mgr.session_info(&did).await {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"has_session": info.has_session,
|
||||||
|
"forward_secrecy": info.forward_secrecy,
|
||||||
|
"message_count": info.message_count,
|
||||||
|
"ratchet_generation": info.ratchet_generation,
|
||||||
|
"peer_did": did,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"has_session": false,
|
||||||
|
"forward_secrecy": false,
|
||||||
|
"message_count": 0,
|
||||||
|
"ratchet_generation": 0,
|
||||||
|
"peer_did": did,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"has_session": false,
|
||||||
|
"forward_secrecy": false,
|
||||||
|
"message_count": 0,
|
||||||
|
"ratchet_generation": 0,
|
||||||
|
"peer_did": null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
174
core/archipelago/src/api/rpc/mesh/typed_messages.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use crate::mesh::message_types::{
|
||||||
|
self, AlertPayload, AlertType, Coordinate, InvoicePayload, MeshMessageType, TypedEnvelope,
|
||||||
|
};
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.send-invoice — Create a Lightning invoice and send bolt11 to mesh peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_send_invoice(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let contact_id = params["contact_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||||
|
let amount_sats = params["amount_sats"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||||
|
let memo = params["memo"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Build invoice payload
|
||||||
|
let invoice = InvoicePayload {
|
||||||
|
bolt11: format!("lnbc{}n1pjmesh...", amount_sats), // Placeholder — real LND call in Phase 4
|
||||||
|
amount_sats,
|
||||||
|
memo: memo.clone(),
|
||||||
|
payment_hash: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = message_types::encode_payload(&invoice)?;
|
||||||
|
let envelope = TypedEnvelope::new(MeshMessageType::Invoice, payload);
|
||||||
|
let wire = envelope.to_wire()?;
|
||||||
|
|
||||||
|
// Send via mesh
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||||
|
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||||
|
|
||||||
|
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"message_id": msg.id,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
"bolt11": invoice.bolt11,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.send-coordinate — Send GPS coordinates to a mesh peer.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_send_coordinate(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let contact_id = params["contact_id"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
|
||||||
|
let lat = params["lat"]
|
||||||
|
.as_f64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing lat"))?;
|
||||||
|
let lng = params["lng"]
|
||||||
|
.as_f64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing lng"))?;
|
||||||
|
let label = params["label"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
|
let coord = Coordinate::from_degrees(lat, lng, label);
|
||||||
|
let payload = message_types::encode_payload(&coord)?;
|
||||||
|
let envelope = TypedEnvelope::new(MeshMessageType::Coordinate, payload);
|
||||||
|
let wire = envelope.to_wire()?;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||||
|
let msg = svc.send_message(contact_id, &wire_str).await?;
|
||||||
|
|
||||||
|
info!(contact_id, "Sent coordinate over mesh");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"message_id": msg.id,
|
||||||
|
"lat": coord.lat,
|
||||||
|
"lng": coord.lng,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.send-alert — Send a signed emergency alert over mesh.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_send_alert(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let message = params["message"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
let alert_type_str = params["alert_type"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("status");
|
||||||
|
let broadcast = params["broadcast"].as_bool().unwrap_or(false);
|
||||||
|
|
||||||
|
let alert_type = match alert_type_str {
|
||||||
|
"emergency" => AlertType::Emergency,
|
||||||
|
"dead_man" => AlertType::DeadMan,
|
||||||
|
_ => AlertType::Status,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional GPS
|
||||||
|
let coordinate = if let (Some(lat), Some(lng)) = (
|
||||||
|
params["lat"].as_f64(),
|
||||||
|
params["lng"].as_f64(),
|
||||||
|
) {
|
||||||
|
Some(Coordinate::from_degrees(lat, lng, None))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let alert = AlertPayload {
|
||||||
|
alert_type,
|
||||||
|
message: message.to_string(),
|
||||||
|
coordinate,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = message_types::encode_payload(&alert)?;
|
||||||
|
|
||||||
|
// Sign the alert with node identity
|
||||||
|
let (_data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let node_key_path = identity_dir.join("node_key");
|
||||||
|
|
||||||
|
let envelope = if node_key_path.exists() {
|
||||||
|
let key_bytes = tokio::fs::read(&node_key_path).await?;
|
||||||
|
if key_bytes.len() == 32 {
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
seed.copy_from_slice(&key_bytes);
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||||
|
TypedEnvelope::new_signed(MeshMessageType::Alert, payload, &signing_key)
|
||||||
|
} else {
|
||||||
|
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TypedEnvelope::new(MeshMessageType::Alert, payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
let wire = envelope.to_wire()?;
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
|
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
||||||
|
if broadcast {
|
||||||
|
// Send on channel (all peers)
|
||||||
|
svc.send_message(0, &wire_str).await?;
|
||||||
|
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||||
|
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||||
|
svc.send_message(contact_id as u32, &wire_str).await?;
|
||||||
|
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"alert_type": alert_type_str,
|
||||||
|
"signed": envelope.sig.is_some(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
119
core/archipelago/src/api/rpc/middleware.rs
Normal file
119
core/archipelago/src/api/rpc/middleware.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use crate::session::SessionStore;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
/// Methods that do not require a valid session cookie.
|
||||||
|
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||||
|
"auth.login",
|
||||||
|
"auth.login.totp",
|
||||||
|
"auth.login.backup",
|
||||||
|
"auth.isOnboardingComplete",
|
||||||
|
"auth.isSetup",
|
||||||
|
"auth.setup",
|
||||||
|
"auth.onboardingComplete",
|
||||||
|
"health",
|
||||||
|
// Server readiness check (Login.vue polls this before showing form)
|
||||||
|
"server.echo",
|
||||||
|
// Onboarding flow (before user has a session — DID creation, signing, backup)
|
||||||
|
"node.did",
|
||||||
|
"node.signChallenge",
|
||||||
|
"node.nostr-pubkey",
|
||||||
|
"node.createBackup",
|
||||||
|
"identity.create",
|
||||||
|
"identity.verify",
|
||||||
|
"identity.resolve-did",
|
||||||
|
// Onboarding restore (before user account exists)
|
||||||
|
"backup.restore-identity",
|
||||||
|
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||||
|
"federation.peer-joined",
|
||||||
|
"federation.peer-address-changed",
|
||||||
|
"federation.peer-did-changed",
|
||||||
|
"federation.get-state",
|
||||||
|
// Fleet telemetry ingest: called by remote nodes posting reports
|
||||||
|
"telemetry.ingest",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Methods whose responses can be cached for a few seconds.
|
||||||
|
pub(super) const CACHEABLE_METHODS: &[&str] = &[
|
||||||
|
"system.stats",
|
||||||
|
"federation.list-nodes",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Sanitize error messages before returning to clients.
|
||||||
|
/// Keeps user-facing validation errors but strips internal system details.
|
||||||
|
pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||||
|
// Allow known validation errors through (these are user-actionable)
|
||||||
|
let user_facing_prefixes = [
|
||||||
|
"Invalid",
|
||||||
|
"Missing",
|
||||||
|
"Not found",
|
||||||
|
"Already exists",
|
||||||
|
"Rate limit",
|
||||||
|
"Unauthorized",
|
||||||
|
"Forbidden",
|
||||||
|
"Not supported",
|
||||||
|
"requires",
|
||||||
|
"must be",
|
||||||
|
"cannot",
|
||||||
|
"Password",
|
||||||
|
"Session",
|
||||||
|
];
|
||||||
|
for prefix in &user_facing_prefixes {
|
||||||
|
if msg.starts_with(prefix) {
|
||||||
|
// Truncate long messages and strip file paths
|
||||||
|
let sanitized = msg.replace("/var/lib/archipelago/", "[data]/")
|
||||||
|
.replace("/usr/local/bin/", "[bin]/")
|
||||||
|
.replace("/etc/", "[config]/");
|
||||||
|
return if sanitized.len() > 200 {
|
||||||
|
format!("{}...", &sanitized[..200])
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For all other errors, return a generic message
|
||||||
|
"Operation failed. Check server logs for details.".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a CSRF token from the session token via HMAC.
|
||||||
|
/// Deterministic: same session token always produces the same CSRF token.
|
||||||
|
/// Survives backend restarts because it depends only on the session token
|
||||||
|
/// and the on-disk remember secret (not ephemeral state).
|
||||||
|
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||||
|
mac.update(format!("csrf:{}", session_token).as_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a named cookie value from headers.
|
||||||
|
pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{}=", name);
|
||||||
|
for value in headers.get_all("cookie") {
|
||||||
|
if let Ok(s) = value.to_str() {
|
||||||
|
for part in s.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(val) = part.strip_prefix(&prefix) {
|
||||||
|
let val = val.trim();
|
||||||
|
if !val.is_empty() {
|
||||||
|
return Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||||
|
pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||||
|
headers
|
||||||
|
.get("x-real-ip")
|
||||||
|
.or_else(|| headers.get("x-forwarded-for"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||||
|
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ impl RpcHandler {
|
|||||||
/// Get the current node visibility setting.
|
/// Get the current node visibility setting.
|
||||||
pub(super) async fn handle_network_get_visibility(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_network_get_visibility(&self) -> Result<serde_json::Value> {
|
||||||
let vis = self.load_visibility().await;
|
let vis = self.load_visibility().await;
|
||||||
let tor_address = docker_packages::read_tor_address("archipelago");
|
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"visibility": vis.as_str(),
|
"visibility": vis.as_str(),
|
||||||
"tor_address": tor_address,
|
"tor_address": tor_address,
|
||||||
@@ -106,7 +106,7 @@ impl RpcHandler {
|
|||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let my_pubkey = &data.server_info.pubkey;
|
let my_pubkey = &data.server_info.pubkey;
|
||||||
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
||||||
let my_onion = docker_packages::read_tor_address("archipelago")
|
let my_onion = docker_packages::read_tor_address("archipelago").await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let req_msg = serde_json::json!({
|
let req_msg = serde_json::json!({
|
||||||
@@ -121,6 +121,8 @@ impl RpcHandler {
|
|||||||
to_onion,
|
to_onion,
|
||||||
my_pubkey,
|
my_pubkey,
|
||||||
&req_msg.to_string(),
|
&req_msg.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
// Also add them as a pending peer locally
|
// Also add them as a pending peer locally
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{backup, identity, nostr_discovery};
|
use crate::{backup, identity, nostr_discovery};
|
||||||
use crate::container::docker_packages;
|
use crate::container::docker_packages;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||||
@@ -68,7 +71,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
||||||
let tor_address = docker_packages::read_tor_address("archipelago");
|
let tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||||
Ok(serde_json::json!({ "tor_address": tor_address }))
|
Ok(serde_json::json!({ "tor_address": tor_address }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,4 +148,81 @@ impl RpcHandler {
|
|||||||
"error": status.error,
|
"error": status.error,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rotate the node's Ed25519 identity keypair.
|
||||||
|
/// Requires password re-verification. Returns a signed proof that peers can
|
||||||
|
/// use to verify the rotation was authorized by the holder of the old key.
|
||||||
|
pub(super) async fn handle_node_rotate_did(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'password' parameter"))?;
|
||||||
|
|
||||||
|
// Re-verify password before allowing key rotation
|
||||||
|
if !self.auth_manager.verify_password(password).await? {
|
||||||
|
anyhow::bail!("Password verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
|
||||||
|
// Load the current identity to get old DID and signing key
|
||||||
|
let old_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
let old_pubkey_hex = old_identity.pubkey_hex();
|
||||||
|
let old_did = identity::did_key_from_pubkey_hex(&old_pubkey_hex)?;
|
||||||
|
|
||||||
|
// Generate a new Ed25519 keypair
|
||||||
|
let new_signing_key = SigningKey::generate(&mut OsRng);
|
||||||
|
let new_pubkey_hex = hex::encode(new_signing_key.verifying_key().as_bytes());
|
||||||
|
let new_did = identity::did_key_from_pubkey_hex(&new_pubkey_hex)?;
|
||||||
|
|
||||||
|
// Create a rotation proof signed by the OLD key:
|
||||||
|
// "did-rotate:{old_did}:{new_did}:{timestamp}"
|
||||||
|
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||||
|
let proof_message = format!("did-rotate:{}:{}:{}", old_did, new_did, timestamp);
|
||||||
|
let proof_signature = old_identity.sign(proof_message.as_bytes());
|
||||||
|
|
||||||
|
// Write the new key files, overwriting the old ones
|
||||||
|
let key_path = identity_dir.join("node_key");
|
||||||
|
let pub_path = identity_dir.join("node_key.pub");
|
||||||
|
|
||||||
|
fs::write(&key_path, new_signing_key.to_bytes())
|
||||||
|
.await
|
||||||
|
.context("Failed to write new node key")?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.await
|
||||||
|
.context("Failed to set key permissions")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&pub_path, new_signing_key.verifying_key().as_bytes())
|
||||||
|
.await
|
||||||
|
.context("Failed to write new node public key")?;
|
||||||
|
|
||||||
|
// Update in-memory state so the new pubkey is reflected immediately
|
||||||
|
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
data.server_info.pubkey = new_pubkey_hex.clone();
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
old_did = %old_did,
|
||||||
|
new_did = %new_did,
|
||||||
|
"Node DID rotated successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"old_did": old_did,
|
||||||
|
"new_did": new_did,
|
||||||
|
"new_pubkey": new_pubkey_hex,
|
||||||
|
"proof_signature": proof_signature,
|
||||||
|
"proof_message": proof_message,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
764
core/archipelago/src/api/rpc/package/config.rs
Normal file
764
core/archipelago/src/api/rpc/package/config.rs
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
use super::validation::validate_app_id;
|
||||||
|
use crate::port_allocator::PortAllocator;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(super) const TRUSTED_REGISTRIES: &[&str] = &["docker.io/", "ghcr.io/", "localhost/", "80.71.235.15:3000/"];
|
||||||
|
|
||||||
|
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
|
||||||
|
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
|
||||||
|
pub(super) fn detect_bitcoin_container_name() -> String {
|
||||||
|
// Synchronous check — called from get_app_config which is sync
|
||||||
|
let output = std::process::Command::new("podman")
|
||||||
|
.args(["ps", "--format", "{{.Names}}"])
|
||||||
|
.output();
|
||||||
|
if let Ok(out) = output {
|
||||||
|
let names = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for candidate in &["bitcoin-knots", "bitcoin-core", "bitcoin"] {
|
||||||
|
if names.lines().any(|l| l.trim() == *candidate) {
|
||||||
|
return candidate.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to bitcoin-knots (most common)
|
||||||
|
"bitcoin-knots".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate Docker image against trusted registry allowlist.
|
||||||
|
pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||||
|
if image.is_empty() || image.len() > 256 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reject shell metacharacters
|
||||||
|
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
||||||
|
if image.chars().any(|c| dangerous_chars.contains(&c)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Must come from a trusted registry — match the exact domain, not just prefix
|
||||||
|
let registry = match image.split('/').next() {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
matches!(registry, "docker.io" | "ghcr.io" | "localhost" | "80.71.235.15:3000")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||||
|
/// Most apps need CHOWN/SETUID/SETGID for internal user switching.
|
||||||
|
pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||||
|
match app_id {
|
||||||
|
// Apps that need user switching and file ownership changes
|
||||||
|
// Home Assistant needs NET_RAW for DHCP discovery
|
||||||
|
"homeassistant" | "home-assistant" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=FOWNER".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
"--cap-add=NET_RAW".to_string(),
|
||||||
|
],
|
||||||
|
"nextcloud" | "btcpay-server" | "btcpayserver"
|
||||||
|
| "jellyfin" | "onlyoffice" | "onlyoffice-documentserver" | "portainer" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// Nginx Proxy Manager needs to bind low ports
|
||||||
|
"nginx-proxy-manager" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
|
||||||
|
| "fedimint-gateway" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=FOWNER".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||||
|
"vaultwarden" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// PhotoPrism uses s6-overlay which needs privilege ops
|
||||||
|
"photoprism" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
],
|
||||||
|
// Grafana runs as specific UID (472)
|
||||||
|
"grafana" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
],
|
||||||
|
// Uptime-kuma startup script needs chown/fowner for /app/data ownership
|
||||||
|
"uptime-kuma" => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=FOWNER".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
],
|
||||||
|
// FileBrowser needs DAC_OVERRIDE for volume access + NET_BIND_SERVICE to bind port 80
|
||||||
|
"filebrowser" => vec![
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
// Default: standard capabilities for rootless podman containers
|
||||||
|
// Most apps need file ownership + port binding to function correctly
|
||||||
|
_ => vec![
|
||||||
|
"--cap-add=CHOWN".to_string(),
|
||||||
|
"--cap-add=FOWNER".to_string(),
|
||||||
|
"--cap-add=SETUID".to_string(),
|
||||||
|
"--cap-add=SETGID".to_string(),
|
||||||
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apps safe to run with --read-only root filesystem.
|
||||||
|
/// These work correctly with volume mounts + tmpfs for /tmp and /run.
|
||||||
|
pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
app_id,
|
||||||
|
"searxng"
|
||||||
|
| "grafana"
|
||||||
|
| "filebrowser"
|
||||||
|
| "electrumx"
|
||||||
|
| "mempool-electrs"
|
||||||
|
| "electrs"
|
||||||
|
| "nostr-rs-relay"
|
||||||
|
| "ollama"
|
||||||
|
| "indeedhub"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get container health check arguments for podman run.
|
||||||
|
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||||
|
pub(super) fn get_health_check_args(app_id: &str, rpc_pass: &str) -> Vec<String> {
|
||||||
|
let btc_health = format!(
|
||||||
|
"bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1",
|
||||||
|
rpc_pass
|
||||||
|
);
|
||||||
|
let (cmd, interval, retries) = match app_id {
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||||
|
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||||
|
"btcpay-server" | "btcpayserver" => {
|
||||||
|
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||||
|
}
|
||||||
|
"mempool-api" => (
|
||||||
|
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"mempool" | "mempool-web" | "archy-mempool-web" => {
|
||||||
|
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||||
|
}
|
||||||
|
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||||
|
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
|
||||||
|
}
|
||||||
|
"nextcloud" => (
|
||||||
|
"curl -sf http://localhost:80/status.php || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"homeassistant" | "home-assistant" => (
|
||||||
|
"curl -sf http://localhost:8123/api/ || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"grafana" => (
|
||||||
|
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"jellyfin" => (
|
||||||
|
"curl -sf http://localhost:8096/health || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
||||||
|
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
||||||
|
"filebrowser" => (
|
||||||
|
"curl -sf http://localhost:80/health || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
||||||
|
"photoprism" => (
|
||||||
|
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
||||||
|
"60s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"immich_server" | "immich" => (
|
||||||
|
"curl -sf http://localhost:2283/api/server/ping || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"dwn" => (
|
||||||
|
"curl -sf http://localhost:3000/health || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"portainer" => (
|
||||||
|
"curl -sf http://localhost:9000/api/status || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||||
|
"fedimint" => (
|
||||||
|
"curl -sf http://localhost:8174/health || exit 1",
|
||||||
|
"60s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
"nostr-rs-relay" | "nostr-relay" => {
|
||||||
|
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||||
|
}
|
||||||
|
"nginx-proxy-manager" => (
|
||||||
|
"curl -sf http://localhost:81/api/ || exit 1",
|
||||||
|
"30s",
|
||||||
|
"3",
|
||||||
|
),
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![
|
||||||
|
format!("--health-cmd={}", cmd),
|
||||||
|
format!("--health-interval={}", interval),
|
||||||
|
format!("--health-retries={}", retries),
|
||||||
|
"--health-start-period=60s".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get per-app memory limit.
|
||||||
|
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||||
|
match app_id {
|
||||||
|
// Heavy apps
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "2g",
|
||||||
|
"onlyoffice" | "onlyoffice-documentserver" => "2g",
|
||||||
|
"ollama" => "4g",
|
||||||
|
// Medium apps
|
||||||
|
"lnd" => "512m",
|
||||||
|
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||||
|
"nextcloud" => "1g",
|
||||||
|
"immich_server" | "immich" => "1g",
|
||||||
|
"btcpay-server" | "btcpayserver" => "1g",
|
||||||
|
"homeassistant" | "home-assistant" => "512m",
|
||||||
|
"fedimint" => "512m",
|
||||||
|
"fedimint-gateway" => "512m",
|
||||||
|
"photoprism" => "1g",
|
||||||
|
// Light apps
|
||||||
|
"mempool-api" => "512m",
|
||||||
|
"mempool" | "mempool-web" | "archy-mempool-web" => "256m",
|
||||||
|
"grafana" => "256m",
|
||||||
|
"jellyfin" => "1g",
|
||||||
|
"vaultwarden" => "256m",
|
||||||
|
"uptime-kuma" => "256m",
|
||||||
|
"filebrowser" => "256m",
|
||||||
|
"searxng" => "512m",
|
||||||
|
"dwn" => "256m",
|
||||||
|
"portainer" => "256m",
|
||||||
|
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||||
|
"nginx-proxy-manager" => "256m",
|
||||||
|
// Databases
|
||||||
|
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
|
||||||
|
"immich_postgres" | "penpot-postgres" => "256m",
|
||||||
|
"immich_redis" | "penpot-valkey" => "128m",
|
||||||
|
// Default
|
||||||
|
_ => "512m",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all container names for an app (handles multi-container apps like mempool)
|
||||||
|
/// All known container name variants for a given app ID.
|
||||||
|
/// This is the single source of truth for container name resolution.
|
||||||
|
/// Every name that could appear in `podman ps` for this app must be listed here.
|
||||||
|
pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||||
|
let base = package_id.to_string();
|
||||||
|
let archy = format!("archy-{}", package_id);
|
||||||
|
|
||||||
|
match package_id {
|
||||||
|
// Bitcoin: multiple historical names
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||||
|
"bitcoin-knots".into(), "bitcoin".into(), "bitcoin-core".into(),
|
||||||
|
"archy-bitcoin-knots".into(), "archy-bitcoin".into(),
|
||||||
|
"bitcoin-ui".into(),
|
||||||
|
],
|
||||||
|
// LND + UI
|
||||||
|
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||||
|
// Electrumx: multiple aliases
|
||||||
|
"electrumx" | "electrs" | "mempool-electrs" => vec![
|
||||||
|
"electrumx".into(), "electrs".into(), "mempool-electrs".into(),
|
||||||
|
"archy-electrumx".into(), "archy-electrs-ui".into(),
|
||||||
|
],
|
||||||
|
// Mempool: multi-container stack
|
||||||
|
"mempool" | "mempool-web" => vec![
|
||||||
|
"mempool".into(), "mempool-web".into(), "mempool-api".into(),
|
||||||
|
"archy-mempool-web".into(), "archy-mempool-api".into(),
|
||||||
|
"archy-mempool-db".into(), "mysql-mempool".into(),
|
||||||
|
],
|
||||||
|
// BTCPay: multi-container + multiple aliases
|
||||||
|
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
||||||
|
"btcpay-server".into(), "btcpay".into(), "btcpayserver".into(),
|
||||||
|
"archy-btcpay".into(), "archy-btcpay-db".into(), "archy-nbxplorer".into(),
|
||||||
|
],
|
||||||
|
// Home Assistant: two naming conventions
|
||||||
|
"homeassistant" | "home-assistant" => vec![
|
||||||
|
"homeassistant".into(), "home-assistant".into(),
|
||||||
|
"archy-homeassistant".into(),
|
||||||
|
],
|
||||||
|
// Fedimint: multiple related containers
|
||||||
|
"fedimint" => vec![
|
||||||
|
"fedimint".into(), "fedimintd".into(),
|
||||||
|
"fedimint-ui".into(), "archy-fedimint".into(),
|
||||||
|
"fedimint-gateway".into(),
|
||||||
|
],
|
||||||
|
"fedimint-gateway" => vec!["fedimint-gateway".into()],
|
||||||
|
// Immich: multi-container
|
||||||
|
"immich" => vec![
|
||||||
|
"immich_postgres".into(), "immich_redis".into(), "immich_server".into(),
|
||||||
|
],
|
||||||
|
// Penpot: multi-container
|
||||||
|
"penpot" | "penpot-frontend" => vec![
|
||||||
|
"penpot-postgres".into(), "penpot-valkey".into(),
|
||||||
|
"penpot-backend".into(), "penpot-exporter".into(), "penpot-frontend".into(),
|
||||||
|
],
|
||||||
|
// Default: exact name + archy- prefix
|
||||||
|
_ => vec![base, archy],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all running/stopped containers that belong to a given app.
|
||||||
|
/// Uses the canonical name list from all_container_names().
|
||||||
|
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
let output = tokio::process::Command::new("podman")
|
||||||
|
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers")?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
|
let patterns = all_container_names(package_id);
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for name in all {
|
||||||
|
if patterns.iter().any(|p| p == name) {
|
||||||
|
result.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get data directories to clean for an app.
|
||||||
|
/// Caller must validate package_id before calling.
|
||||||
|
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||||
|
let base = "/var/lib/archipelago";
|
||||||
|
match package_id {
|
||||||
|
"mempool" | "mempool-web" => vec![
|
||||||
|
format!("{}/mempool", base),
|
||||||
|
format!("{}/mysql-mempool", base),
|
||||||
|
format!("{}/electrumx", base),
|
||||||
|
format!("{}/mempool-electrs", base),
|
||||||
|
],
|
||||||
|
"fedimint" => vec![
|
||||||
|
format!("{}/fedimint", base),
|
||||||
|
format!("{}/fedimint-gateway", base),
|
||||||
|
],
|
||||||
|
"fedimint-gateway" => vec![format!("{}/fedimint-gateway", base)],
|
||||||
|
"immich" => vec![
|
||||||
|
format!("{}/immich", base),
|
||||||
|
format!("{}/immich-db", base),
|
||||||
|
],
|
||||||
|
"penpot" | "penpot-frontend" => vec![
|
||||||
|
format!("{}/penpot-assets", base),
|
||||||
|
format!("{}/penpot-postgres", base),
|
||||||
|
],
|
||||||
|
_ => vec![format!("{}/{}", base, package_id)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get app-specific configuration
|
||||||
|
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
|
||||||
|
pub(super) async fn get_app_config(
|
||||||
|
app_id: &str,
|
||||||
|
host_ip: &str,
|
||||||
|
allocator: &mut PortAllocator,
|
||||||
|
rpc_user: &str,
|
||||||
|
rpc_pass: &str,
|
||||||
|
) -> (
|
||||||
|
Vec<String>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<Vec<String>>,
|
||||||
|
) {
|
||||||
|
match app_id {
|
||||||
|
"homeassistant" | "home-assistant" => (
|
||||||
|
vec!["8123:8123".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
||||||
|
vec!["TZ=UTC".to_string()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
||||||
|
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"lnd" => (
|
||||||
|
vec![
|
||||||
|
"9735:9735".to_string(),
|
||||||
|
"10009:10009".to_string(),
|
||||||
|
"8080:8080".to_string(),
|
||||||
|
],
|
||||||
|
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some(vec![
|
||||||
|
"--bitcoin.active".to_string(),
|
||||||
|
"--bitcoin.mainnet".to_string(),
|
||||||
|
"--bitcoin.node=bitcoind".to_string(),
|
||||||
|
format!("--bitcoind.rpcuser={}", rpc_user),
|
||||||
|
format!("--bitcoind.rpcpass={}", rpc_pass),
|
||||||
|
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
|
||||||
|
"--bitcoind.zmqpubrawblock=tcp://bitcoin-knots:28332".to_string(),
|
||||||
|
"--bitcoind.zmqpubrawtx=tcp://bitcoin-knots:28333".to_string(),
|
||||||
|
"--rpclisten=0.0.0.0:10009".to_string(),
|
||||||
|
"--restlisten=0.0.0.0:8080".to_string(),
|
||||||
|
"--listen=0.0.0.0:9735".to_string(),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
"btcpay-server" | "btcpayserver" => (
|
||||||
|
vec!["23000:49392".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
|
||||||
|
vec![
|
||||||
|
"ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(),
|
||||||
|
"BTCPAY_PROTOCOL=http".to_string(),
|
||||||
|
format!("BTCPAY_HOST={}:23000", host_ip),
|
||||||
|
"BTCPAY_CHAINS=btc".to_string(),
|
||||||
|
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
||||||
|
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
|
||||||
|
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
|
||||||
|
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"mempool" | "mempool-web" => (
|
||||||
|
vec!["4080:8080".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"mempool-api" => (
|
||||||
|
vec!["8999:8999".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||||
|
vec![
|
||||||
|
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||||
|
"ELECTRUM_HOST=electrumx".to_string(),
|
||||||
|
"ELECTRUM_PORT=50001".to_string(),
|
||||||
|
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||||
|
format!("CORE_RPC_HOST={}", host_ip),
|
||||||
|
"CORE_RPC_PORT=8332".to_string(),
|
||||||
|
format!("CORE_RPC_USERNAME={}", rpc_user),
|
||||||
|
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||||
|
"DATABASE_ENABLED=true".to_string(),
|
||||||
|
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||||
|
"DATABASE_DATABASE=mempool".to_string(),
|
||||||
|
"DATABASE_USERNAME=mempool".to_string(),
|
||||||
|
"DATABASE_PASSWORD=mempoolpass".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||||
|
// Detect which bitcoin container is running for archy-net DNS resolution
|
||||||
|
let bitcoin_host = detect_bitcoin_container_name();
|
||||||
|
(
|
||||||
|
vec!["50001:50001".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||||
|
vec![
|
||||||
|
format!(
|
||||||
|
"DAEMON_URL=http://{}:{}@{}:8332/",
|
||||||
|
rpc_user, rpc_pass, bitcoin_host
|
||||||
|
),
|
||||||
|
"COIN=Bitcoin".to_string(),
|
||||||
|
"DB_DIRECTORY=/data".to_string(),
|
||||||
|
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"mysql-mempool" => (
|
||||||
|
vec![],
|
||||||
|
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],
|
||||||
|
vec![
|
||||||
|
"MYSQL_DATABASE=mempool".to_string(),
|
||||||
|
"MYSQL_USER=mempool".to_string(),
|
||||||
|
"MYSQL_PASSWORD=mempoolpass".to_string(),
|
||||||
|
"MYSQL_ROOT_PASSWORD=rootpass".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"grafana" => (
|
||||||
|
vec!["3000:3000".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
||||||
|
vec![
|
||||||
|
"GF_PATHS_DATA=/var/lib/grafana".to_string(),
|
||||||
|
"GF_USERS_ALLOW_SIGN_UP=false".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"searxng" => (
|
||||||
|
vec!["8888:8080".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/searxng:/etc/searxng".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"ollama" => (
|
||||||
|
vec!["11434:11434".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"onlyoffice" | "onlyoffice-documentserver" => (
|
||||||
|
vec!["9980:80".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"penpot" | "penpot-frontend" => (
|
||||||
|
vec!["9001:80".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"nextcloud" => {
|
||||||
|
let host_port = allocator
|
||||||
|
.allocate_or_get(app_id, 8085, 80)
|
||||||
|
.await
|
||||||
|
.unwrap_or(8085);
|
||||||
|
(
|
||||||
|
vec![format!("{}:80", host_port)],
|
||||||
|
vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"vaultwarden" => {
|
||||||
|
let host_port = allocator
|
||||||
|
.allocate_or_get(app_id, 8082, 80)
|
||||||
|
.await
|
||||||
|
.unwrap_or(8082);
|
||||||
|
(
|
||||||
|
vec![format!("{}:80", host_port)],
|
||||||
|
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"jellyfin" => (
|
||||||
|
vec!["8096:8096".to_string()],
|
||||||
|
vec![
|
||||||
|
"/var/lib/archipelago/jellyfin/config:/config".to_string(),
|
||||||
|
"/var/lib/archipelago/jellyfin/cache:/cache".to_string(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"photoprism" => (
|
||||||
|
vec!["2342:2342".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
||||||
|
vec![
|
||||||
|
"PHOTOPRISM_ADMIN_PASSWORD=archipelago".to_string(),
|
||||||
|
"PHOTOPRISM_DEFAULT_LOCALE=en".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"immich" => (
|
||||||
|
vec!["2283:2283".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
||||||
|
vec![
|
||||||
|
"DB_HOSTNAME=immich_postgres".to_string(),
|
||||||
|
"DB_USERNAME=postgres".to_string(),
|
||||||
|
"DB_PASSWORD=immichpass".to_string(),
|
||||||
|
"DB_DATABASE_NAME=immich".to_string(),
|
||||||
|
"REDIS_HOSTNAME=immich_redis".to_string(),
|
||||||
|
"UPLOAD_LOCATION=/usr/src/app/upload".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"filebrowser" => {
|
||||||
|
let host_port = allocator
|
||||||
|
.allocate_or_get(app_id, 8083, 80)
|
||||||
|
.await
|
||||||
|
.unwrap_or(8083);
|
||||||
|
(
|
||||||
|
vec![format!("{}:80", host_port)],
|
||||||
|
vec![
|
||||||
|
"/var/lib/archipelago/filebrowser:/srv".to_string(),
|
||||||
|
"/var/lib/archipelago/filebrowser-data:/data".to_string(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some(vec![
|
||||||
|
"--database=/data/database.db".to_string(),
|
||||||
|
"--root=/srv".to_string(),
|
||||||
|
"--address=0.0.0.0".to_string(),
|
||||||
|
"--port=80".to_string(),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"nginx-proxy-manager" => (
|
||||||
|
vec![
|
||||||
|
"81:81".to_string(),
|
||||||
|
"8084:80".to_string(),
|
||||||
|
"8443:443".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||||
|
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"portainer" => (
|
||||||
|
vec!["9000:9000".to_string()],
|
||||||
|
vec![
|
||||||
|
"/var/lib/archipelago/portainer:/data".to_string(),
|
||||||
|
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"uptime-kuma" => (
|
||||||
|
vec!["3001:3001".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||||
|
vec!["TZ=UTC".to_string()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"tailscale" => (
|
||||||
|
vec!["8240:8240".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
|
||||||
|
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
|
||||||
|
Some(
|
||||||
|
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"fedimint" => (
|
||||||
|
vec![
|
||||||
|
"8173:8173".to_string(),
|
||||||
|
"8174:8174".to_string(),
|
||||||
|
"8175:8175".to_string(),
|
||||||
|
],
|
||||||
|
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
|
||||||
|
vec![
|
||||||
|
"FM_DATA_DIR=/data".to_string(),
|
||||||
|
format!("FM_BITCOIND_USERNAME={}", rpc_user),
|
||||||
|
format!("FM_BITCOIND_PASSWORD={}", rpc_pass),
|
||||||
|
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
|
||||||
|
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
|
||||||
|
"FM_BIND_API=0.0.0.0:8174".to_string(),
|
||||||
|
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
||||||
|
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
||||||
|
format!("FM_API_URL=ws://{}:8174", host_ip),
|
||||||
|
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
Some(vec![
|
||||||
|
"--data-dir".to_string(),
|
||||||
|
"/data".to_string(),
|
||||||
|
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
"fedimint-gateway" => (
|
||||||
|
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some(vec![
|
||||||
|
"gatewayd".to_string(),
|
||||||
|
"--data-dir".to_string(),
|
||||||
|
"/data".to_string(),
|
||||||
|
"--listen".to_string(),
|
||||||
|
"0.0.0.0:8176".to_string(),
|
||||||
|
"--bcrypt-password-hash".to_string(),
|
||||||
|
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||||
|
"--network".to_string(),
|
||||||
|
"bitcoin".to_string(),
|
||||||
|
"--bitcoind-url".to_string(),
|
||||||
|
format!("http://{}:8332", host_ip),
|
||||||
|
"--bitcoind-username".to_string(),
|
||||||
|
rpc_user.to_string(),
|
||||||
|
"--bitcoind-password".to_string(),
|
||||||
|
rpc_pass.to_string(),
|
||||||
|
"ldk".to_string(),
|
||||||
|
"--ldk-lightning-port".to_string(),
|
||||||
|
"9737".to_string(),
|
||||||
|
"--ldk-alias".to_string(),
|
||||||
|
"archipelago-gateway".to_string(),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
"indeedhub" => (
|
||||||
|
vec!["8190:3000".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
"NODE_ENV=production".to_string(),
|
||||||
|
"NEXT_TELEMETRY_DISABLED=1".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"nostr-rs-relay" => (
|
||||||
|
vec!["18081:8080".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"dwn" => (
|
||||||
|
vec!["3100:3000".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
|
||||||
|
vec![
|
||||||
|
"DS_PORT=3000".to_string(),
|
||||||
|
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
|
||||||
|
"DS_DATA_STORE_URI=level://data/data".to_string(),
|
||||||
|
"DS_EVENT_LOG_URI=level://data/events".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
_ => (vec![], vec![], vec![], None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
216
core/archipelago/src/api/rpc/package/dependencies.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
use super::config::get_containers_for_app;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Names of container variants that represent a running Bitcoin node
|
||||||
|
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||||
|
|
||||||
|
/// Names of container variants that represent a running Electrum indexer
|
||||||
|
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||||
|
|
||||||
|
/// Snapshot of which dependency services are currently running.
|
||||||
|
pub(super) struct RunningDeps {
|
||||||
|
pub has_bitcoin: bool,
|
||||||
|
pub has_electrumx: bool,
|
||||||
|
pub has_lnd: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query podman for currently running containers and return dependency status.
|
||||||
|
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||||
|
let dep_check = tokio::process::Command::new("podman")
|
||||||
|
.args(["ps", "--format", "{{.Names}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||||
|
|
||||||
|
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||||
|
let is_running = |names: &[&str]| {
|
||||||
|
running.lines().any(|l| {
|
||||||
|
let name = l.trim();
|
||||||
|
names.iter().any(|n| name == *n)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RunningDeps {
|
||||||
|
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||||
|
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||||
|
has_lnd: is_running(&["lnd"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that required dependency services are running before installing an app.
|
||||||
|
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||||
|
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||||
|
match package_id {
|
||||||
|
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
|
Please install and start Bitcoin Knots first."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||||
|
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
|
Please install and start Bitcoin Knots first."
|
||||||
|
)),
|
||||||
|
"btcpay-server" | "btcpayserver" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||||
|
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
|
Please install and start Bitcoin Knots first."
|
||||||
|
)),
|
||||||
|
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||||
|
let mut missing = vec![];
|
||||||
|
if !deps.has_bitcoin {
|
||||||
|
missing.push("Bitcoin Knots");
|
||||||
|
}
|
||||||
|
if !deps.has_electrumx {
|
||||||
|
missing.push("ElectrumX");
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Mempool requires {} to be running. Please install and start {} first.",
|
||||||
|
missing.join(" and "),
|
||||||
|
missing.join(" and ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||||
|
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
|
Please install and start Bitcoin Knots first."
|
||||||
|
)),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log informational messages about optional dependencies.
|
||||||
|
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||||
|
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||||
|
tracing::info!(
|
||||||
|
"BTCPay Server installing without LND \
|
||||||
|
— Lightning payments won't be available until LND is installed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an app requires the shared `archy-net` Podman network for
|
||||||
|
/// inter-container DNS resolution.
|
||||||
|
pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
package_id,
|
||||||
|
"bitcoin-knots"
|
||||||
|
| "bitcoin"
|
||||||
|
| "bitcoin-core"
|
||||||
|
| "lnd"
|
||||||
|
| "mempool"
|
||||||
|
| "mempool-web"
|
||||||
|
| "mempool-api"
|
||||||
|
| "electrumx"
|
||||||
|
| "mempool-electrs"
|
||||||
|
| "electrs"
|
||||||
|
| "mysql-mempool"
|
||||||
|
| "archy-mempool-db"
|
||||||
|
| "archy-mempool-web"
|
||||||
|
| "btcpay-server"
|
||||||
|
| "btcpayserver"
|
||||||
|
| "archy-btcpay-db"
|
||||||
|
| "archy-nbxplorer"
|
||||||
|
| "nbxplorer"
|
||||||
|
| "fedimint"
|
||||||
|
| "fedimint-gateway"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the correct startup order for a multi-container app stack.
|
||||||
|
/// Containers are started in this order to satisfy dependency chains.
|
||||||
|
pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||||
|
match package_id {
|
||||||
|
"mempool" | "mempool-web" => &[
|
||||||
|
"archy-mempool-db",
|
||||||
|
"mysql-mempool",
|
||||||
|
"electrumx",
|
||||||
|
"mempool-electrs",
|
||||||
|
"mempool-api",
|
||||||
|
"archy-mempool-api",
|
||||||
|
"archy-mempool-web",
|
||||||
|
"mempool",
|
||||||
|
],
|
||||||
|
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||||
|
"penpot" | "penpot-frontend" => &[
|
||||||
|
"penpot-postgres",
|
||||||
|
"penpot-valkey",
|
||||||
|
"penpot-backend",
|
||||||
|
"penpot-exporter",
|
||||||
|
"penpot-frontend",
|
||||||
|
],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort a list of container names according to the dependency-aware startup
|
||||||
|
/// order for the given app. Unknown containers sort to the end.
|
||||||
|
pub(super) async fn ordered_containers_for_start(
|
||||||
|
package_id: &str,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
|
if containers.is_empty() {
|
||||||
|
return Ok(vec![format!("archy-{}", package_id)]);
|
||||||
|
}
|
||||||
|
let order = startup_order(package_id);
|
||||||
|
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||||
|
let effective_order: &[&str] = if order.is_empty() {
|
||||||
|
startup_order("mempool")
|
||||||
|
} else {
|
||||||
|
order
|
||||||
|
};
|
||||||
|
let mut sorted = containers;
|
||||||
|
sorted.sort_by_key(|c| {
|
||||||
|
effective_order
|
||||||
|
.iter()
|
||||||
|
.position(|o| *o == c)
|
||||||
|
.unwrap_or(99)
|
||||||
|
});
|
||||||
|
Ok(sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||||
|
/// Modifies ports, volumes, and command args in place when LND credentials exist.
|
||||||
|
pub(super) fn configure_fedimint_lnd(
|
||||||
|
host_ip: &str,
|
||||||
|
ports: &mut Vec<String>,
|
||||||
|
volumes: &mut Vec<String>,
|
||||||
|
custom_args: &mut Option<Vec<String>>,
|
||||||
|
rpc_user: &str,
|
||||||
|
rpc_pass: &str,
|
||||||
|
) {
|
||||||
|
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||||
|
let lnd_macaroon =
|
||||||
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
if std::path::Path::new(lnd_cert).exists()
|
||||||
|
&& std::path::Path::new(lnd_macaroon).exists()
|
||||||
|
{
|
||||||
|
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||||
|
ports.retain(|p| p != "9737:9737");
|
||||||
|
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||||
|
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||||
|
*custom_args = Some(vec![
|
||||||
|
"gatewayd".to_string(),
|
||||||
|
"--data-dir".to_string(),
|
||||||
|
"/data".to_string(),
|
||||||
|
"--listen".to_string(),
|
||||||
|
"0.0.0.0:8176".to_string(),
|
||||||
|
"--bcrypt-password-hash".to_string(),
|
||||||
|
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||||
|
"--network".to_string(),
|
||||||
|
"bitcoin".to_string(),
|
||||||
|
"--bitcoind-url".to_string(),
|
||||||
|
format!("http://{}:8332", host_ip),
|
||||||
|
"--bitcoind-username".to_string(),
|
||||||
|
rpc_user.to_string(),
|
||||||
|
"--bitcoind-password".to_string(),
|
||||||
|
rpc_pass.to_string(),
|
||||||
|
"lnd".to_string(),
|
||||||
|
"--lnd-rpc-host".to_string(),
|
||||||
|
format!("{}:10009", host_ip),
|
||||||
|
"--lnd-tls-cert".to_string(),
|
||||||
|
"/lnd/tls.cert".to_string(),
|
||||||
|
"--lnd-macaroon".to_string(),
|
||||||
|
"/lnd/admin.macaroon".to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
703
core/archipelago/src/api/rpc/package/install.rs
Normal file
703
core/archipelago/src/api/rpc/package/install.rs
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
use super::config::{
|
||||||
|
get_app_capabilities, get_app_config, get_health_check_args, get_memory_limit,
|
||||||
|
is_readonly_compatible, is_valid_docker_image,
|
||||||
|
};
|
||||||
|
use super::dependencies::{
|
||||||
|
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
|
||||||
|
needs_archy_net,
|
||||||
|
};
|
||||||
|
use super::progress::parse_pull_progress;
|
||||||
|
use super::validation::validate_app_id;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Install a package from a Docker image.
|
||||||
|
/// Security: Image verification, resource limits, network isolation.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_install(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
let docker_image = params
|
||||||
|
.get("dockerImage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Installing package {} from image {}",
|
||||||
|
package_id, docker_image
|
||||||
|
);
|
||||||
|
|
||||||
|
if !is_valid_docker_image(docker_image) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-container stacks get their own install path
|
||||||
|
if package_id == "immich" {
|
||||||
|
return self.install_immich_stack().await;
|
||||||
|
}
|
||||||
|
if package_id == "penpot" || package_id == "penpot-frontend" {
|
||||||
|
return self.install_penpot_stack().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency checks
|
||||||
|
let deps = detect_running_deps().await?;
|
||||||
|
check_install_deps(package_id, &deps)?;
|
||||||
|
log_optional_dep_info(package_id, &deps);
|
||||||
|
|
||||||
|
// Check if container already exists
|
||||||
|
let check_output = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
&format!("name=^{}$", package_id),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to check existing containers")?;
|
||||||
|
|
||||||
|
if !String::from_utf8_lossy(&check_output.stdout)
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Container {} already exists. Stop and remove it first.",
|
||||||
|
package_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull or verify image
|
||||||
|
let has_local_fallback = self
|
||||||
|
.pull_or_verify_image(package_id, docker_image)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Normalize container name for legacy aliases
|
||||||
|
let container_name = match package_id {
|
||||||
|
"electrs" | "mempool-electrs" => "electrumx",
|
||||||
|
_ => package_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read Bitcoin RPC credentials for container configs
|
||||||
|
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||||
|
|
||||||
|
// App-specific configuration
|
||||||
|
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
|
||||||
|
let mut allocator = self.port_allocator.lock().await;
|
||||||
|
get_app_config(
|
||||||
|
package_id,
|
||||||
|
&self.config.host_ip,
|
||||||
|
&mut allocator,
|
||||||
|
&rpc_user,
|
||||||
|
&rpc_pass,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fedimint Gateway: auto-detect LND and switch to lnd mode
|
||||||
|
if package_id == "fedimint-gateway" && deps.has_lnd {
|
||||||
|
configure_fedimint_lnd(
|
||||||
|
&self.config.host_ip,
|
||||||
|
&mut ports,
|
||||||
|
&mut volumes,
|
||||||
|
&mut custom_args,
|
||||||
|
&rpc_user,
|
||||||
|
&rpc_pass,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the podman run command
|
||||||
|
let mut run_args = vec![
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
container_name,
|
||||||
|
"--restart=unless-stopped",
|
||||||
|
];
|
||||||
|
|
||||||
|
let is_tailscale = package_id == "tailscale";
|
||||||
|
|
||||||
|
// Network mode
|
||||||
|
if is_tailscale {
|
||||||
|
run_args.push("--network=host");
|
||||||
|
run_args.push("--privileged");
|
||||||
|
run_args.push("--cap-add=NET_ADMIN");
|
||||||
|
run_args.push("--cap-add=NET_RAW");
|
||||||
|
run_args.push("--device=/dev/net/tun");
|
||||||
|
} else if needs_archy_net(package_id) {
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["network", "create", "archy-net"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
run_args.push("--network=archy-net");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security hardening (skip for privileged containers)
|
||||||
|
let security_caps: Vec<String> = if !is_tailscale {
|
||||||
|
get_app_capabilities(package_id)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
|
||||||
|
|
||||||
|
if !is_tailscale {
|
||||||
|
run_args.push("--cap-drop=ALL");
|
||||||
|
run_args.push("--security-opt=no-new-privileges:true");
|
||||||
|
for cap in &security_caps {
|
||||||
|
run_args.push(cap);
|
||||||
|
}
|
||||||
|
if readonly_compatible {
|
||||||
|
run_args.push("--read-only");
|
||||||
|
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
|
||||||
|
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data directories
|
||||||
|
self.create_data_dirs(package_id, &volumes).await;
|
||||||
|
|
||||||
|
// Pre-install: bitcoin.conf with rpcauth
|
||||||
|
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||||
|
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-install: SearXNG settings.yml (required or container exits immediately)
|
||||||
|
if package_id == "searxng" {
|
||||||
|
let searx_dir = "/var/lib/archipelago/searxng";
|
||||||
|
let settings_path = format!("{}/settings.yml", searx_dir);
|
||||||
|
if !tokio::fs::try_exists(&settings_path).await.unwrap_or(false) {
|
||||||
|
let secret: [u8; 32] = rand::random();
|
||||||
|
let secret_hex = hex::encode(secret);
|
||||||
|
let settings = format!(
|
||||||
|
"use_default_settings: true\ngeneral:\n instance_name: Archipelago Search\nserver:\n secret_key: \"{}\"\n bind_address: \"0.0.0.0\"\n port: 8080\n limiter: false\nui:\n default_theme: simple\n",
|
||||||
|
secret_hex
|
||||||
|
);
|
||||||
|
let _ = tokio::fs::write(&settings_path, settings).await;
|
||||||
|
info!("Created SearXNG settings.yml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port mappings (skip for host-network containers)
|
||||||
|
if !is_tailscale {
|
||||||
|
for port in &ports {
|
||||||
|
run_args.push("-p");
|
||||||
|
run_args.push(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume mounts
|
||||||
|
for volume in &volumes {
|
||||||
|
run_args.push("-v");
|
||||||
|
run_args.push(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
for env in &env_vars {
|
||||||
|
run_args.push("-e");
|
||||||
|
run_args.push(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
let memory_limit = get_memory_limit(package_id);
|
||||||
|
let mem_arg = format!("--memory={}", memory_limit);
|
||||||
|
run_args.push(&mem_arg);
|
||||||
|
run_args.push("--cpus=2");
|
||||||
|
|
||||||
|
// Health checks
|
||||||
|
let health_args = get_health_check_args(package_id, &rpc_pass);
|
||||||
|
for arg in &health_args {
|
||||||
|
run_args.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image — prefer local build over registry
|
||||||
|
let effective_image = if has_local_fallback {
|
||||||
|
format!("localhost/{}:latest", package_id)
|
||||||
|
} else {
|
||||||
|
docker_image.to_string()
|
||||||
|
};
|
||||||
|
run_args.push(&effective_image);
|
||||||
|
|
||||||
|
debug!("Running container with args: {:?}", run_args);
|
||||||
|
|
||||||
|
// Build command with optional custom command/args
|
||||||
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
|
cmd.args(&run_args);
|
||||||
|
if let Some(custom_cmd) = custom_command {
|
||||||
|
cmd.arg(custom_cmd);
|
||||||
|
} else if let Some(args) = custom_args {
|
||||||
|
cmd.args(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_output = cmd.output().await.context("Failed to run container")?;
|
||||||
|
|
||||||
|
if !run_output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
||||||
|
// Rollback: remove partially created container
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", container_name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let container_id = String::from_utf8_lossy(&run_output.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Post-start health verification: wait up to 30s for container to be running
|
||||||
|
for i in 0..6u32 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
let status = tokio::process::Command::new("podman")
|
||||||
|
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(o) = status {
|
||||||
|
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||||
|
if state == "running" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if state == "exited" {
|
||||||
|
// Container crashed immediately — get logs for diagnosis
|
||||||
|
let logs = tokio::process::Command::new("podman")
|
||||||
|
.args(["logs", "--tail", "20", container_name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let log_output = logs
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stderr).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", container_name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Container {} exited immediately after start. Logs: {}",
|
||||||
|
container_name,
|
||||||
|
log_output.chars().take(500).collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i == 5 {
|
||||||
|
debug!("Container {} health check timeout (30s) — continuing anyway", container_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-install hooks
|
||||||
|
self.run_post_install_hooks(package_id).await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"package_id": package_id,
|
||||||
|
"container_id": container_id,
|
||||||
|
"message": format!("Package {} installed and started", package_id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private helpers for install --
|
||||||
|
|
||||||
|
/// Pull the image from a registry or verify a local image exists.
|
||||||
|
/// Returns `true` if a local fallback image was found (registry pull skipped).
|
||||||
|
async fn pull_or_verify_image(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
docker_image: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let is_local_image = docker_image.starts_with("localhost/");
|
||||||
|
let has_local_fallback = if !is_local_image {
|
||||||
|
let local_tag = format!("localhost/{}:latest", package_id);
|
||||||
|
let check = tokio::process::Command::new("podman")
|
||||||
|
.args(["images", "-q", &local_tag])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
check.map_or(false, |o| {
|
||||||
|
!String::from_utf8_lossy(&o.stdout).trim().is_empty()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_local_image && !has_local_fallback {
|
||||||
|
self.pull_image_with_progress(package_id, docker_image)
|
||||||
|
.await?;
|
||||||
|
} else if has_local_fallback {
|
||||||
|
debug!(
|
||||||
|
"Using local build for {} (skipping registry pull)",
|
||||||
|
package_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Local image — verify it exists
|
||||||
|
let images_output = tokio::process::Command::new("podman")
|
||||||
|
.args(["images", "-q", docker_image])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to check local image")?;
|
||||||
|
if String::from_utf8_lossy(&images_output.stdout)
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Local image {} not found. Build the image first \
|
||||||
|
or ensure the registry is reachable.",
|
||||||
|
docker_image
|
||||||
|
));
|
||||||
|
}
|
||||||
|
debug!("Using local image: {}", docker_image);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(has_local_fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s).
|
||||||
|
async fn pull_image_with_progress(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
docker_image: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
const MAX_ATTEMPTS: u32 = 3;
|
||||||
|
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||||
|
|
||||||
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
|
match self.do_pull_image(package_id, docker_image).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) if attempt < MAX_ATTEMPTS => {
|
||||||
|
let delay = BACKOFF_SECS[(attempt - 1) as usize];
|
||||||
|
tracing::warn!(
|
||||||
|
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
|
||||||
|
docker_image, attempt, MAX_ATTEMPTS, e, delay
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.clear_install_progress(package_id).await;
|
||||||
|
return Err(e.context(format!(
|
||||||
|
"Failed to pull {} after {} attempts",
|
||||||
|
docker_image, MAX_ATTEMPTS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single image pull attempt with progress streaming.
|
||||||
|
async fn do_pull_image(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
docker_image: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
debug!("Pulling image: {}", docker_image);
|
||||||
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
|
|
||||||
|
let mut child = tokio::process::Command::new("podman")
|
||||||
|
.args(["pull", docker_image])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to start image pull")?;
|
||||||
|
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
let pkg_id = package_id.to_string();
|
||||||
|
let state_mgr = self.state_manager.clone();
|
||||||
|
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||||
|
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.context("Failed to wait for image pull")?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow::anyhow!("podman pull exited with non-zero status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify image exists locally after pull
|
||||||
|
let verify = tokio::process::Command::new("podman")
|
||||||
|
.args(["images", "-q", docker_image])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to verify pulled image")?;
|
||||||
|
if String::from_utf8_lossy(&verify.stdout).trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Image {} not found locally after pull",
|
||||||
|
docker_image
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_install_progress(package_id, 100, 100).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create data directories for volume mounts under /var/lib/archipelago/.
|
||||||
|
/// Get the mapped host UID for a container's internal UID.
|
||||||
|
/// Rootless podman maps container UIDs: host_uid = subuid_start + container_uid
|
||||||
|
/// Default subuid start for archipelago user is 100000.
|
||||||
|
fn mapped_uid(package_id: &str) -> u32 {
|
||||||
|
let container_uid = match package_id {
|
||||||
|
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => 101,
|
||||||
|
"grafana" => 472,
|
||||||
|
"lnd" => 1000,
|
||||||
|
"mariadb" | "mysql" | "mysql-mempool" | "archy-mempool-db" => 999,
|
||||||
|
"postgres" | "btcpay-postgres" | "immich-postgres" | "penpot-postgres"
|
||||||
|
| "archy-btcpay-db" | "nextcloud-db" => 70,
|
||||||
|
"electrumx" | "electrs" => 1000,
|
||||||
|
_ => 0, // Most containers run as root (UID 0)
|
||||||
|
};
|
||||||
|
100000 + container_uid
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
|
||||||
|
let uid = Self::mapped_uid(package_id);
|
||||||
|
let uid_str = format!("{}:{}", uid, uid);
|
||||||
|
|
||||||
|
for volume in volumes {
|
||||||
|
if let Some(host_path) = volume.split(':').next() {
|
||||||
|
if host_path.starts_with("/var/lib/archipelago/") {
|
||||||
|
debug!("Creating directory: {} (owner: {})", host_path, uid_str);
|
||||||
|
let create_dir = tokio::process::Command::new("sudo")
|
||||||
|
.args(["mkdir", "-p", host_path])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Err(e) = create_dir {
|
||||||
|
debug!("Failed to create directory {}: {}", host_path, e);
|
||||||
|
}
|
||||||
|
// Set ownership to the mapped UID for rootless podman
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["chown", "-R", &uid_str, host_path])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||||
|
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) {
|
||||||
|
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||||
|
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
let salt_bytes: [u8; 16] = rand::random();
|
||||||
|
let salt_hex = hex::encode(salt_bytes);
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||||
|
.expect("HMAC accepts any key length");
|
||||||
|
mac.update(rpc_pass.as_bytes());
|
||||||
|
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||||
|
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||||
|
|
||||||
|
let bitcoin_conf = format!(
|
||||||
|
"\
|
||||||
|
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||||
|
{}\n\
|
||||||
|
server=1\n\
|
||||||
|
prune=550\n\
|
||||||
|
rpcbind=0.0.0.0\n\
|
||||||
|
rpcallowip=0.0.0.0/0\n\
|
||||||
|
rpcport=8332\n\
|
||||||
|
listen=1\n\
|
||||||
|
printtoconsole=1\n",
|
||||||
|
rpcauth_line
|
||||||
|
);
|
||||||
|
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
|
||||||
|
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
|
||||||
|
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||||
|
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||||
|
if package_id == "filebrowser" {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Wait for filebrowser to start and initialize its database
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Generate a random password (32 bytes, hex-encoded)
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||||
|
let password = hex::encode(buf);
|
||||||
|
|
||||||
|
// Get a JWT token with default credentials
|
||||||
|
let login_res = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.post("http://127.0.0.1:8083/api/login")
|
||||||
|
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let token = match login_res {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
resp.text().await.unwrap_or_default().trim_matches('"').to_string()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("FileBrowser not ready for password change — keeping default");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change admin password via filebrowser API
|
||||||
|
let change_res = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.put("http://127.0.0.1:8083/api/users/1")
|
||||||
|
.header("X-Auth", &token)
|
||||||
|
.json(&serde_json::json!({"password": password}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match change_res {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let secret_dir = "/var/lib/archipelago/secrets/filebrowser";
|
||||||
|
let _ = tokio::fs::create_dir_all(secret_dir).await;
|
||||||
|
let _ = tokio::fs::write(
|
||||||
|
format!("{}/password", secret_dir),
|
||||||
|
&password,
|
||||||
|
).await;
|
||||||
|
info!("FileBrowser admin password secured (default credentials replaced)");
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
tracing::warn!("FileBrowser password change failed: {}", resp.status());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("FileBrowser password change error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if package_id == "nextcloud" {
|
||||||
|
let host_ip = self.config.host_ip.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Wait for Nextcloud to finish first-run initialization
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||||
|
for domain_idx in 1..=2u8 {
|
||||||
|
let value = if domain_idx == 1 {
|
||||||
|
host_ip.as_str()
|
||||||
|
} else {
|
||||||
|
"localhost"
|
||||||
|
};
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"exec",
|
||||||
|
"-u",
|
||||||
|
"33",
|
||||||
|
"nextcloud",
|
||||||
|
"php",
|
||||||
|
"occ",
|
||||||
|
"config:system:set",
|
||||||
|
"trusted_domains",
|
||||||
|
&domain_idx.to_string(),
|
||||||
|
"--value",
|
||||||
|
value,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
info!("Nextcloud trusted domains configured for {}", host_ip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and start companion UI containers for headless services
|
||||||
|
let ui_builds: Vec<(&str, &str, &str, &str)> = match package_id {
|
||||||
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||||
|
vec![("bitcoin-ui", "/opt/archipelago/docker/bitcoin-ui", "localhost/bitcoin-ui", "8334:80")]
|
||||||
|
}
|
||||||
|
"lnd" => {
|
||||||
|
vec![("archy-lnd-ui", "/opt/archipelago/docker/lnd-ui", "localhost/lnd-ui", "8081:80")]
|
||||||
|
}
|
||||||
|
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||||
|
vec![("archy-electrs-ui", "/opt/archipelago/docker/electrs-ui", "localhost/electrs-ui", "50002:80")]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (name, ui_dir, image, port) in ui_builds {
|
||||||
|
let name = name.to_string();
|
||||||
|
let ui_dir = ui_dir.to_string();
|
||||||
|
let image = image.to_string();
|
||||||
|
let port = port.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if !std::path::Path::new(&ui_dir).exists() {
|
||||||
|
info!("UI source not found at {}, skipping", ui_dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!("Building UI container {} from {}", name, ui_dir);
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["build", "-t", &image, &ui_dir])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", &name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run", "-d",
|
||||||
|
"--name", &name,
|
||||||
|
"--restart=unless-stopped",
|
||||||
|
"--network=archy-net",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--cap-add=NET_BIND_SERVICE",
|
||||||
|
"--memory=64m",
|
||||||
|
"-p", &port,
|
||||||
|
&format!("{}:latest", image),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
info!("{} UI container started on port {}", name, port);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a fresh FileBrowser JWT token for the frontend.
|
||||||
|
/// Reads the stored random password and authenticates to filebrowser's API.
|
||||||
|
pub(in crate::api::rpc) async fn handle_filebrowser_token(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password";
|
||||||
|
let password = tokio::fs::read_to_string(secret_path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "admin".to_string());
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("http://127.0.0.1:8083/api/login")
|
||||||
|
.json(&serde_json::json!({"username": "admin", "password": password}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to FileBrowser")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("FileBrowser login failed ({})", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = resp.text().await.unwrap_or_default();
|
||||||
|
let token = token.trim_matches('"');
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "token": token }))
|
||||||
|
}
|
||||||
|
}
|
||||||
9
core/archipelago/src/api/rpc/package/lifecycle.rs
Normal file
9
core/archipelago/src/api/rpc/package/lifecycle.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Container lifecycle operations.
|
||||||
|
//
|
||||||
|
// Split into focused sub-modules:
|
||||||
|
// - install.rs — Image pulling, container creation, volume setup, multi-container stacks
|
||||||
|
// - runtime.rs — Start, stop, restart, uninstall operations
|
||||||
|
// - dependencies.rs — Dependency resolution, startup ordering, network requirements
|
||||||
|
//
|
||||||
|
// All public handler methods (handle_package_*) are implemented on RpcHandler
|
||||||
|
// in their respective sub-modules and remain callable from the RPC dispatcher.
|
||||||
11
core/archipelago/src/api/rpc/package/mod.rs
Normal file
11
core/archipelago/src/api/rpc/package/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mod config;
|
||||||
|
mod dependencies;
|
||||||
|
mod install;
|
||||||
|
mod lifecycle;
|
||||||
|
mod progress;
|
||||||
|
mod runtime;
|
||||||
|
mod stacks;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||||
|
pub(super) use validation::validate_app_id;
|
||||||
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
141
core/archipelago/src/api/rpc/package/progress.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! Install progress tracking and podman pull output parsing.
|
||||||
|
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::data_model::{
|
||||||
|
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Set install progress for a package and broadcast the update.
|
||||||
|
/// Creates a minimal package entry if one doesn't exist yet.
|
||||||
|
pub(super) async fn set_install_progress(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
downloaded: u64,
|
||||||
|
size: u64,
|
||||||
|
) {
|
||||||
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
let entry = data
|
||||||
|
.package_data
|
||||||
|
.entry(package_id.to_string())
|
||||||
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
|
entry.state = PackageState::Installing;
|
||||||
|
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear install progress after pull completes or fails.
|
||||||
|
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||||
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
|
entry.install_progress = None;
|
||||||
|
}
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update install progress (static method for use in async closures).
|
||||||
|
pub(super) async fn update_install_progress(
|
||||||
|
state_manager: &crate::state::StateManager,
|
||||||
|
package_id: &str,
|
||||||
|
downloaded: u64,
|
||||||
|
total: u64,
|
||||||
|
) {
|
||||||
|
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||||
|
let entry = data
|
||||||
|
.package_data
|
||||||
|
.entry(package_id.to_string())
|
||||||
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
|
entry.install_progress = Some(InstallProgress {
|
||||||
|
size: total,
|
||||||
|
downloaded,
|
||||||
|
});
|
||||||
|
state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a minimal PackageDataEntry for a package being installed.
|
||||||
|
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||||
|
PackageDataEntry {
|
||||||
|
state: PackageState::Installing,
|
||||||
|
health: None,
|
||||||
|
static_files: StaticFiles {
|
||||||
|
license: String::new(),
|
||||||
|
instructions: String::new(),
|
||||||
|
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||||
|
},
|
||||||
|
manifest: Manifest {
|
||||||
|
id: package_id.to_string(),
|
||||||
|
title: package_id.to_string(),
|
||||||
|
version: String::new(),
|
||||||
|
description: Description {
|
||||||
|
short: "Installing...".to_string(),
|
||||||
|
long: String::new(),
|
||||||
|
},
|
||||||
|
release_notes: String::new(),
|
||||||
|
license: String::new(),
|
||||||
|
wrapper_repo: String::new(),
|
||||||
|
upstream_repo: String::new(),
|
||||||
|
support_site: String::new(),
|
||||||
|
marketing_site: String::new(),
|
||||||
|
donation_url: None,
|
||||||
|
author: None,
|
||||||
|
website: None,
|
||||||
|
interfaces: None,
|
||||||
|
tier: None,
|
||||||
|
},
|
||||||
|
installed: None,
|
||||||
|
install_progress: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse podman pull progress output.
|
||||||
|
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
|
||||||
|
/// Returns (downloaded_bytes, total_bytes) if parseable.
|
||||||
|
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
|
||||||
|
let line = line.trim();
|
||||||
|
let parts: Vec<&str> = line.split('/').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloaded = parse_size_value(parts[0].trim())?;
|
||||||
|
let total = parse_size_value(parts[1].trim())?;
|
||||||
|
|
||||||
|
if total > 0 {
|
||||||
|
Some((downloaded, total))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
|
||||||
|
fn parse_size_value(s: &str) -> Option<u64> {
|
||||||
|
let s = s.trim();
|
||||||
|
|
||||||
|
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||||
|
(
|
||||||
|
s[..pos].trim().split_whitespace().last()?,
|
||||||
|
1024 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
} else if let Some(pos) = s.rfind("MiB") {
|
||||||
|
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
|
||||||
|
} else if let Some(pos) = s.rfind("KiB") {
|
||||||
|
(s[..pos].trim().split_whitespace().last()?, 1024)
|
||||||
|
} else if let Some(pos) = s.rfind("GB") {
|
||||||
|
(
|
||||||
|
s[..pos].trim().split_whitespace().last()?,
|
||||||
|
1_000_000_000,
|
||||||
|
)
|
||||||
|
} else if let Some(pos) = s.rfind("MB") {
|
||||||
|
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
|
||||||
|
} else if let Some(pos) = s.rfind("KB") {
|
||||||
|
(s[..pos].trim().split_whitespace().last()?, 1_000)
|
||||||
|
} else if let Some(pos) = s.rfind('B') {
|
||||||
|
(s[..pos].trim().split_whitespace().last()?, 1)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let num: f64 = num_str.parse().ok()?;
|
||||||
|
Some((num * multiplier as f64) as u64)
|
||||||
|
}
|
||||||
421
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
421
core/archipelago/src/api/rpc/package/runtime.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||||
|
use super::dependencies::ordered_containers_for_start;
|
||||||
|
use super::validation::validate_app_id;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Per-container graceful shutdown timeout in seconds.
|
||||||
|
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
|
||||||
|
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
|
||||||
|
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
||||||
|
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
|
||||||
|
match id {
|
||||||
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
|
||||||
|
"lnd" => "330",
|
||||||
|
"electrumx" | "electrs" | "mempool-electrs" => "300",
|
||||||
|
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
||||||
|
| "nextcloud-db" | "endurain-db" => "120",
|
||||||
|
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
|
||||||
|
_ => "30",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Start a package: start all containers in dependency order.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_start(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
let to_start = ordered_containers_for_start(package_id).await?;
|
||||||
|
if to_start.is_empty() {
|
||||||
|
tracing::warn!("package.start {}: no containers found", package_id);
|
||||||
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user-stopped flag — user explicitly started this app
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||||
|
for name in &to_start {
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for name in &to_start {
|
||||||
|
tracing::info!("Starting container: {}", name);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to exec podman start {}", name))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
tracing::error!("Failed to start {}: {}", name, stderr);
|
||||||
|
errors.push(format!("{}: {}", name, stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")));
|
||||||
|
}
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a package: mark as user-stopped and stop all containers.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_stop(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
|
if containers.is_empty() {
|
||||||
|
tracing::warn!("package.stop {}: no containers found", package_id);
|
||||||
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||||
|
for name in &containers {
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for name in &containers {
|
||||||
|
tracing::info!("Stopping container: {} (timeout: {}s)", name, stop_timeout_secs(name));
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to exec podman stop {}", name))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
tracing::error!("Failed to stop {}: {}", name, stderr);
|
||||||
|
errors.push(format!("{}: {}", name, stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")));
|
||||||
|
}
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart a package: restart all containers.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_restart(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
|
if containers.is_empty() {
|
||||||
|
tracing::warn!("package.restart {}: no containers found", package_id);
|
||||||
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for name in &containers {
|
||||||
|
tracing::info!("Restarting container: {}", name);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["restart", "-t", stop_timeout_secs(name), name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to exec podman restart {}", name))?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
tracing::warn!("podman restart {} failed: {}, trying stop+start", name, stderr);
|
||||||
|
|
||||||
|
// Fallback: stop then start (handles rootless podman loopback issues)
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let start_out = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to exec podman start {}", name))?;
|
||||||
|
|
||||||
|
if !start_out.status.success() {
|
||||||
|
let start_err = String::from_utf8_lossy(&start_out.stderr).trim().to_string();
|
||||||
|
tracing::error!("stop+start {} also failed: {}", name, start_err);
|
||||||
|
errors.push(format!("{}: {}", name, start_err));
|
||||||
|
} else {
|
||||||
|
tracing::info!("Restarted {} via stop+start fallback", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
|
||||||
|
}
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstall a package: stop and remove all related containers, clean data.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_uninstall(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
let preserve_data = params
|
||||||
|
.get("preserve_data")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let containers_to_remove = get_containers_for_app(package_id).await?;
|
||||||
|
if containers_to_remove.is_empty() {
|
||||||
|
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stopped = 0u32;
|
||||||
|
let mut removed = 0u32;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for name in &containers_to_remove {
|
||||||
|
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||||
|
let stop_out = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match stop_out {
|
||||||
|
Ok(o) if o.status.success() => stopped += 1,
|
||||||
|
Ok(o) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
tracing::warn!(
|
||||||
|
"Uninstall {}: stop {} failed: {}",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Uninstall {}: stop {} error: {}",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||||
|
let rm_out = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match rm_out {
|
||||||
|
Ok(o) if o.status.success() => removed += 1,
|
||||||
|
Ok(o) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
|
||||||
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
|
errors.push(msg);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("Failed to remove {}: {}", name, e);
|
||||||
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
|
errors.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release port allocation
|
||||||
|
{
|
||||||
|
let mut allocator = self.port_allocator.lock().await;
|
||||||
|
let _ = allocator.release(package_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean data directories unless preserve_data
|
||||||
|
if !preserve_data {
|
||||||
|
let data_dirs = get_data_dirs_for_app(package_id);
|
||||||
|
for dir in &data_dirs {
|
||||||
|
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||||
|
let rm_out = tokio::process::Command::new("sudo")
|
||||||
|
.args(["rm", "-rf", dir])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(o) = rm_out {
|
||||||
|
if !o.status.success() {
|
||||||
|
tracing::warn!("Uninstall {}: rm {} failed", package_id, dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
tracing::error!(
|
||||||
|
"Uninstall {} completed with errors: {:?}",
|
||||||
|
package_id,
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"Uninstall {} complete: stopped={}, removed={}",
|
||||||
|
package_id,
|
||||||
|
stopped,
|
||||||
|
removed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": if errors.is_empty() { "uninstalled" } else { "partial" },
|
||||||
|
"stopped": stopped,
|
||||||
|
"removed": removed,
|
||||||
|
"errors": errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||||
|
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
||||||
|
&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("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
validate_app_id(app_id)?;
|
||||||
|
let image = params
|
||||||
|
.get("image")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
|
||||||
|
if !is_valid_docker_image(image) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||||
|
}
|
||||||
|
let ports = params
|
||||||
|
.get("ports")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
|
||||||
|
let volumes = params
|
||||||
|
.get("volumes")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||||
|
|
||||||
|
let check_output = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
&format!("name={}", app_id),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to check container")?;
|
||||||
|
|
||||||
|
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||||
|
|
||||||
|
if existing.trim().is_empty() {
|
||||||
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
|
cmd.args(["run", "-d", "--name", app_id]);
|
||||||
|
|
||||||
|
for port in ports {
|
||||||
|
if let (Some(host), Some(container)) = (
|
||||||
|
port.get("host").and_then(|v| v.as_u64()),
|
||||||
|
port.get("container").and_then(|v| v.as_u64()),
|
||||||
|
) {
|
||||||
|
cmd.arg("-p").arg(format!("{}:{}", host, container));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for volume in volumes {
|
||||||
|
if let (Some(host), Some(container)) = (
|
||||||
|
volume.get("host").and_then(|v| v.as_str()),
|
||||||
|
volume.get("container").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
// Validate host path: must be under /var/lib/archipelago/
|
||||||
|
if !host.starts_with("/var/lib/archipelago/")
|
||||||
|
|| host.contains("..")
|
||||||
|
|| host.contains('\0')
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Volume host path must be under /var/lib/archipelago/ \
|
||||||
|
and cannot contain path traversal"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if container.contains("..") || container.contains('\0') {
|
||||||
|
return Err(anyhow::anyhow!("Invalid container mount path"));
|
||||||
|
}
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["mkdir", "-p", host])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
cmd.arg("-v").arg(format!("{}:{}", host, container));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(image);
|
||||||
|
|
||||||
|
let output = cmd.output().await.context("Failed to create container")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let output = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", app_id])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to start container")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "started", "app_id": app_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a bundled app.
|
||||||
|
pub(in crate::api::rpc) async fn handle_bundled_app_stop(
|
||||||
|
&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("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
validate_app_id(app_id)?;
|
||||||
|
|
||||||
|
let output = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to stop container")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
364
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
364
core/archipelago/src/api/rpc/package/stacks.rs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
//! Multi-container app stack installers (Immich, Penpot).
|
||||||
|
//!
|
||||||
|
//! Each stack pulls multiple images, creates a private network, and starts
|
||||||
|
//! containers in dependency order.
|
||||||
|
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||||
|
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||||
|
const MAX_ATTEMPTS: u32 = 3;
|
||||||
|
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||||
|
|
||||||
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
|
let output = tokio::process::Command::new("podman")
|
||||||
|
.args(["pull", image])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to execute podman pull")?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < MAX_ATTEMPTS {
|
||||||
|
let delay = BACKOFF_SECS[(attempt - 1) as usize];
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
tracing::warn!(
|
||||||
|
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
|
||||||
|
image, attempt, MAX_ATTEMPTS, stderr.trim(), delay
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to pull {} after {} attempts: {}",
|
||||||
|
image, MAX_ATTEMPTS, stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Install Immich stack (postgres + redis + server).
|
||||||
|
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
let check = tokio::process::Command::new("podman")
|
||||||
|
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers")?;
|
||||||
|
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||||
|
if stdout.contains("immich_server") {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Immich already installed. Stop and remove it first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "immich"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", "immich"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let images = [
|
||||||
|
"80.71.235.15:3000/archipelago/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||||
|
"80.71.235.15:3000/archipelago/valkey:7-alpine",
|
||||||
|
"80.71.235.15:3000/archipelago/immich-server:release",
|
||||||
|
];
|
||||||
|
for img in &images {
|
||||||
|
pull_image_with_retry(img).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args([
|
||||||
|
"mkdir",
|
||||||
|
"-p",
|
||||||
|
"/var/lib/archipelago/immich",
|
||||||
|
"/var/lib/archipelago/immich-db",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["network", "create", "immich-net"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"immich_postgres",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"immich-net",
|
||||||
|
"-v",
|
||||||
|
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_PASSWORD=immichpass",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_USER=postgres",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_DB=immich",
|
||||||
|
"80.71.235.15:3000/archipelago/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"immich_redis",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"immich-net",
|
||||||
|
"80.71.235.15:3000/archipelago/valkey:7-alpine",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
let run = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"immich_server",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"immich-net",
|
||||||
|
"-p",
|
||||||
|
"2283:2283",
|
||||||
|
"-v",
|
||||||
|
"/var/lib/archipelago/immich:/usr/src/app/upload",
|
||||||
|
"-e",
|
||||||
|
"DB_HOSTNAME=immich_postgres",
|
||||||
|
"-e",
|
||||||
|
"DB_USERNAME=postgres",
|
||||||
|
"-e",
|
||||||
|
"DB_PASSWORD=immichpass",
|
||||||
|
"-e",
|
||||||
|
"DB_DATABASE_NAME=immich",
|
||||||
|
"-e",
|
||||||
|
"REDIS_HOSTNAME=immich_redis",
|
||||||
|
"-e",
|
||||||
|
"UPLOAD_LOCATION=/usr/src/app/upload",
|
||||||
|
"80.71.235.15:3000/archipelago/immich-server:release",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to start immich_server")?;
|
||||||
|
|
||||||
|
if !run.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to start Immich server: {}",
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Immich stack installed and started");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"package_id": "immich",
|
||||||
|
"message": "Immich stack installed and started"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
|
||||||
|
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
let check = tokio::process::Command::new("podman")
|
||||||
|
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers")?;
|
||||||
|
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||||
|
if stdout.contains("penpot-frontend") {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Penpot already installed. Stop and remove it first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let images = [
|
||||||
|
"80.71.235.15:3000/archipelago/postgres:15",
|
||||||
|
"80.71.235.15:3000/archipelago/valkey:8.1",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-backend:2.4",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-exporter:2.4",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
||||||
|
];
|
||||||
|
for img in &images {
|
||||||
|
pull_image_with_retry(img).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["network", "create", "penpot-net"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Generate a stable secret key derived from the data directory
|
||||||
|
let secret = {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"penpot-secret-");
|
||||||
|
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
};
|
||||||
|
let host_ip = &self.config.host_ip;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"penpot-postgres",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"penpot-net",
|
||||||
|
"-v",
|
||||||
|
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_DB=penpot",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_USER=penpot",
|
||||||
|
"-e",
|
||||||
|
"POSTGRES_PASSWORD=penpot",
|
||||||
|
"80.71.235.15:3000/archipelago/postgres:15",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"penpot-valkey",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"penpot-net",
|
||||||
|
"-e",
|
||||||
|
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||||
|
"80.71.235.15:3000/archipelago/valkey:8.1",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"penpot-backend",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"penpot-net",
|
||||||
|
"-v",
|
||||||
|
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||||
|
"-e",
|
||||||
|
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||||
|
"-e",
|
||||||
|
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||||
|
"-e",
|
||||||
|
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_DATABASE_USERNAME=penpot",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_DATABASE_PASSWORD=penpot",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-backend:2.4",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"penpot-exporter",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"penpot-net",
|
||||||
|
"-e",
|
||||||
|
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||||
|
"-e",
|
||||||
|
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
||||||
|
"-e",
|
||||||
|
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-exporter:2.4",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
let run = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"penpot-frontend",
|
||||||
|
"--restart",
|
||||||
|
"unless-stopped",
|
||||||
|
"--network",
|
||||||
|
"penpot-net",
|
||||||
|
"-p",
|
||||||
|
"9001:8080",
|
||||||
|
"-v",
|
||||||
|
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||||
|
"-e",
|
||||||
|
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||||
|
"-e",
|
||||||
|
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||||
|
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to start penpot-frontend")?;
|
||||||
|
|
||||||
|
if !run.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to start Penpot frontend: {}",
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Penpot stack installed and started");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"package_id": "penpot",
|
||||||
|
"message": "Penpot stack installed and started"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
core/archipelago/src/api/rpc/package/validation.rs
Normal file
18
core/archipelago/src/api/rpc/package/validation.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
|
||||||
|
pub(in crate::api::rpc) fn validate_app_id(id: &str) -> Result<()> {
|
||||||
|
if id.is_empty() || id.len() > 64 {
|
||||||
|
anyhow::bail!("Invalid app id: must be 1-64 characters");
|
||||||
|
}
|
||||||
|
if !id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
{
|
||||||
|
anyhow::bail!("Invalid app id: only lowercase letters, digits, and hyphens allowed");
|
||||||
|
}
|
||||||
|
if id.starts_with('-') {
|
||||||
|
anyhow::bail!("Invalid app id: must not start with a hyphen");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -89,7 +89,25 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let pubkey = data.server_info.pubkey.clone();
|
let pubkey = data.server_info.pubkey.clone();
|
||||||
node_message::send_to_peer(onion, &pubkey, message).await?;
|
|
||||||
|
// Load signing key for E2E encryption
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
|
||||||
|
// Look up recipient's pubkey from federation nodes
|
||||||
|
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
||||||
|
let recipient_pubkey = fed_nodes.iter()
|
||||||
|
.find(|n| n.onion == onion || n.onion == format!("{}.onion", onion)
|
||||||
|
|| format!("{}.onion", n.onion) == onion)
|
||||||
|
.map(|n| n.pubkey.clone());
|
||||||
|
|
||||||
|
node_message::send_to_peer(
|
||||||
|
onion,
|
||||||
|
&pubkey,
|
||||||
|
message,
|
||||||
|
Some(node_id.signing_key()),
|
||||||
|
recipient_pubkey.as_deref(),
|
||||||
|
).await?;
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +129,20 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "messages": messages }))
|
Ok(serde_json::json!({ "messages": messages }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a sent message for Archipelago channel history persistence.
|
||||||
|
pub(super) async fn handle_node_store_sent(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
node_message::store_sent(message);
|
||||||
|
Ok(serde_json::json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let nodes = nostr_discovery::discover_archipelago_nodes(
|
let nodes = nostr_discovery::discover_archipelago_nodes(
|
||||||
|
|||||||
67
core/archipelago/src/api/rpc/response.rs
Normal file
67
core/archipelago/src/api/rpc/response.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use hyper::{Response, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct RpcRequest {
|
||||||
|
pub method: String,
|
||||||
|
pub params: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub(super) struct RpcResponse {
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
pub error: Option<RpcError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub(super) struct RpcError {
|
||||||
|
pub code: i32,
|
||||||
|
pub message: String,
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple TTL cache for read-only RPC responses.
|
||||||
|
pub(super) struct ResponseCache {
|
||||||
|
entries: tokio::sync::RwLock<std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>>,
|
||||||
|
ttl: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseCache {
|
||||||
|
pub fn new(ttl_secs: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||||
|
ttl: std::time::Duration::from_secs(ttl_secs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, key: &str) -> Option<serde_json::Value> {
|
||||||
|
let entries = self.entries.read().await;
|
||||||
|
if let Some((ts, value)) = entries.get(key) {
|
||||||
|
if ts.elapsed() < self.ttl {
|
||||||
|
return Some(value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(&self, key: String, value: serde_json::Value) {
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.insert(key, (std::time::Instant::now(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a JSON HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||||
|
pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::Body> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(body.to_vec()))
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Response::new(hyper::Body::from(r#"{"error":{"code":500,"message":"Internal error"}}"#))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Set-Cookie header value, returning a default if parsing fails.
|
||||||
|
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
|
||||||
|
value.parse().unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
|
||||||
|
}
|
||||||
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
316
core/archipelago/src/api/rpc/system/handlers.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
||||||
|
pub(in crate::api::rpc) async fn handle_server_set_name(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if name.is_empty() || name.len() > 64 {
|
||||||
|
anyhow::bail!("Name must be 1-64 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to file
|
||||||
|
let name_file = self.config.data_dir.join("server-name");
|
||||||
|
tokio::fs::write(&name_file, &name)
|
||||||
|
.await
|
||||||
|
.context("Failed to write server name")?;
|
||||||
|
|
||||||
|
// Update live state
|
||||||
|
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
data.server_info.name = Some(name.clone());
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
|
||||||
|
info!("Server name updated to: {}", name);
|
||||||
|
|
||||||
|
// Push the new name to federation peers in background
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
let state_manager = self.state_manager.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||||
|
debug!("Federation name push (non-fatal): {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "name": name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
||||||
|
debug!("Getting system stats");
|
||||||
|
|
||||||
|
let uptime = read_uptime().await.unwrap_or(0.0);
|
||||||
|
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
||||||
|
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
||||||
|
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||||
|
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"uptime_secs": uptime as u64,
|
||||||
|
"load_avg_1": load.0,
|
||||||
|
"load_avg_5": load.1,
|
||||||
|
"load_avg_15": load.2,
|
||||||
|
"cpu_usage_percent": cpu,
|
||||||
|
"mem_used_bytes": mem_used,
|
||||||
|
"mem_total_bytes": mem_total,
|
||||||
|
"disk_used_bytes": disk_used,
|
||||||
|
"disk_total_bytes": disk_total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.processes — top 10 processes by CPU
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
||||||
|
debug!("Getting top processes");
|
||||||
|
|
||||||
|
let procs = read_top_processes().await.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "processes": procs }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.temperature — thermal zone readings
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
||||||
|
debug!("Getting system temperature");
|
||||||
|
|
||||||
|
let temps = read_temperatures().await.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "temperatures": temps }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
||||||
|
debug!("Scanning for USB hardware wallets");
|
||||||
|
|
||||||
|
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "devices": devices }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.disk-status — Disk usage with warning/critical thresholds.
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
|
||||||
|
let percent = if total > 0 {
|
||||||
|
(used as f64 / total as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let percent_rounded = (percent * 10.0).round() / 10.0;
|
||||||
|
|
||||||
|
let level = if percent >= 90.0 {
|
||||||
|
"critical"
|
||||||
|
} else if percent >= 85.0 {
|
||||||
|
"warning"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"used_bytes": used,
|
||||||
|
"total_bytes": total,
|
||||||
|
"free_bytes": total.saturating_sub(used),
|
||||||
|
"used_percent": percent_rounded,
|
||||||
|
"level": level,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
||||||
|
tracing::info!("Starting disk cleanup");
|
||||||
|
let mut freed_bytes: u64 = 0;
|
||||||
|
let mut actions: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// 1. Prune dangling container images
|
||||||
|
match prune_container_images().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if bytes > 0 {
|
||||||
|
freed_bytes += bytes;
|
||||||
|
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clean old log files (> 30 days)
|
||||||
|
match clean_old_logs(30).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if bytes > 0 {
|
||||||
|
freed_bytes += bytes;
|
||||||
|
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove stale temp files
|
||||||
|
match clean_temp_files().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if bytes > 0 {
|
||||||
|
freed_bytes += bytes;
|
||||||
|
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prune container build cache
|
||||||
|
match prune_build_cache().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if bytes > 0 {
|
||||||
|
freed_bytes += bytes;
|
||||||
|
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"freed_bytes": freed_bytes,
|
||||||
|
"freed_human": format_bytes(freed_bytes),
|
||||||
|
"actions": actions,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||||
|
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||||
|
/// system.reboot — Reboot the machine. Requires password re-verification.
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_reboot(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let password = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("password"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow::anyhow!("Password incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("System reboot initiated by user");
|
||||||
|
|
||||||
|
// Schedule reboot in 2 seconds (gives time for the RPC response to reach the client)
|
||||||
|
// Uses the tor-helper path unit pattern (writes action file, systemd triggers root service)
|
||||||
|
tokio::spawn(async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
let action = serde_json::json!({"action": "reboot"});
|
||||||
|
let _ = tokio::fs::write(
|
||||||
|
"/var/lib/archipelago/tor-config/tor-action",
|
||||||
|
serde_json::to_string(&action).unwrap_or_default(),
|
||||||
|
).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "rebooting": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_factory_reset(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
// Safety check: require { confirm: true }
|
||||||
|
let confirmed = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("confirm"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !confirmed {
|
||||||
|
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require password re-authentication for destructive operations
|
||||||
|
let password = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("password"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||||
|
|
||||||
|
let data_dir = &self.config.data_dir;
|
||||||
|
|
||||||
|
// 1. Stop and remove ALL containers (force)
|
||||||
|
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||||
|
if let Ok(containers) = client.list_containers().await {
|
||||||
|
for c in &containers {
|
||||||
|
tracing::info!("Factory reset: removing container {}", c.name);
|
||||||
|
let _ = client.stop_container(&c.name).await;
|
||||||
|
let _ = client.remove_container(&c.name).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove all container images
|
||||||
|
tracing::info!("Factory reset: pruning all container images");
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rmi", "--all", "--force"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 3. Prune volumes and build cache
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["volume", "prune", "-f"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["system", "prune", "-af"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Wipe the entire data directory contents
|
||||||
|
// Delete everything inside data_dir, then recreate the empty dir.
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
|
||||||
|
// Skip the tor directory (managed by system debian-tor user)
|
||||||
|
if name_str == "tor" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Factory reset: removing {}", path.display());
|
||||||
|
if path.is_dir() {
|
||||||
|
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Clear all sessions
|
||||||
|
self.session_store.invalidate_all_except("").await;
|
||||||
|
|
||||||
|
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||||
|
|
||||||
|
// Restart the service via systemd
|
||||||
|
tokio::spawn(async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
let _ = std::process::Command::new("sudo")
|
||||||
|
.args(["systemctl", "restart", "archipelago"])
|
||||||
|
.spawn();
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "resetting" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,179 +1,48 @@
|
|||||||
use super::RpcHandler;
|
mod handlers;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::debug;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
impl RpcHandler {
|
/// Push the server name to all federation peers by syncing state.
|
||||||
/// server.set-name — Rename the server (persisted to data_dir/server-name)
|
pub(super) async fn push_name_to_peers(
|
||||||
pub(super) async fn handle_server_set_name(
|
data_dir: &std::path::Path,
|
||||||
&self,
|
state_manager: &std::sync::Arc<crate::state::StateManager>,
|
||||||
params: Option<serde_json::Value>,
|
) -> Result<()> {
|
||||||
) -> Result<serde_json::Value> {
|
use crate::{federation, identity};
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if name.is_empty() || name.len() > 64 {
|
let nodes = federation::load_nodes(data_dir).await?;
|
||||||
anyhow::bail!("Name must be 1-64 characters");
|
if nodes.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = state_manager.get_snapshot().await;
|
||||||
|
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
let identity_dir = data_dir.join("identity");
|
||||||
|
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
|
||||||
|
let mut synced = 0u32;
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trust_level == federation::TrustLevel::Untrusted {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
match federation::sync_with_peer(
|
||||||
// Persist to file
|
data_dir,
|
||||||
let name_file = self.config.data_dir.join("server-name");
|
node,
|
||||||
tokio::fs::write(&name_file, &name)
|
&local_did,
|
||||||
.await
|
|bytes| node_identity.sign(bytes),
|
||||||
.context("Failed to write server name")?;
|
)
|
||||||
|
.await
|
||||||
// Update live state
|
{
|
||||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
Ok(_) => synced += 1,
|
||||||
data.server_info.name = Some(name.clone());
|
Err(e) => debug!("Sync with {} after rename: {}", node.did.chars().take(20).collect::<String>(), e),
|
||||||
self.state_manager.update_data(data).await;
|
|
||||||
|
|
||||||
debug!("Server name updated to: {}", name);
|
|
||||||
Ok(serde_json::json!({ "name": name }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
|
|
||||||
pub(super) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
|
|
||||||
debug!("Getting system stats");
|
|
||||||
|
|
||||||
let uptime = read_uptime().await.unwrap_or(0.0);
|
|
||||||
let load = read_loadavg().await.unwrap_or((0.0, 0.0, 0.0));
|
|
||||||
let cpu = read_cpu_usage().await.unwrap_or(0.0);
|
|
||||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
|
||||||
let (disk_used, disk_total) = read_disk_usage().await.unwrap_or((0, 0));
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"uptime_secs": uptime as u64,
|
|
||||||
"load_avg_1": load.0,
|
|
||||||
"load_avg_5": load.1,
|
|
||||||
"load_avg_15": load.2,
|
|
||||||
"cpu_usage_percent": cpu,
|
|
||||||
"mem_used_bytes": mem_used,
|
|
||||||
"mem_total_bytes": mem_total,
|
|
||||||
"disk_used_bytes": disk_used,
|
|
||||||
"disk_total_bytes": disk_total,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.processes — top 10 processes by CPU
|
|
||||||
pub(super) async fn handle_system_processes(&self) -> Result<serde_json::Value> {
|
|
||||||
debug!("Getting top processes");
|
|
||||||
|
|
||||||
let procs = read_top_processes().await.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "processes": procs }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.temperature — thermal zone readings
|
|
||||||
pub(super) async fn handle_system_temperature(&self) -> Result<serde_json::Value> {
|
|
||||||
debug!("Getting system temperature");
|
|
||||||
|
|
||||||
let temps = read_temperatures().await.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "temperatures": temps }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
|
||||||
pub(super) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
|
||||||
debug!("Scanning for USB hardware wallets");
|
|
||||||
|
|
||||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "devices": devices }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.disk-status — Disk usage with warning/critical thresholds.
|
|
||||||
pub(super) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
|
||||||
let (used, total) = read_disk_usage().await.unwrap_or((0, 0));
|
|
||||||
let percent = if total > 0 {
|
|
||||||
(used as f64 / total as f64) * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let percent_rounded = (percent * 10.0).round() / 10.0;
|
|
||||||
|
|
||||||
let level = if percent >= 90.0 {
|
|
||||||
"critical"
|
|
||||||
} else if percent >= 85.0 {
|
|
||||||
"warning"
|
|
||||||
} else {
|
|
||||||
"ok"
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"used_bytes": used,
|
|
||||||
"total_bytes": total,
|
|
||||||
"free_bytes": total.saturating_sub(used),
|
|
||||||
"used_percent": percent_rounded,
|
|
||||||
"level": level,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
|
||||||
pub(super) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
|
||||||
tracing::info!("Starting disk cleanup");
|
|
||||||
let mut freed_bytes: u64 = 0;
|
|
||||||
let mut actions: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
// 1. Prune dangling container images
|
|
||||||
match prune_container_images().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes > 0 {
|
|
||||||
freed_bytes += bytes;
|
|
||||||
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Clean old log files (> 30 days)
|
|
||||||
match clean_old_logs(30).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes > 0 {
|
|
||||||
freed_bytes += bytes;
|
|
||||||
actions.push(format!("Cleaned old logs: {} freed", format_bytes(bytes)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Remove stale temp files
|
|
||||||
match clean_temp_files().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes > 0 {
|
|
||||||
freed_bytes += bytes;
|
|
||||||
actions.push(format!("Removed temp files: {} freed", format_bytes(bytes)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Prune container build cache
|
|
||||||
match prune_build_cache().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes > 0 {
|
|
||||||
freed_bytes += bytes;
|
|
||||||
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"freed_bytes": freed_bytes,
|
|
||||||
"freed_human": format_bytes(freed_bytes),
|
|
||||||
"actions": actions,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
info!("Pushed server name to {}/{} peers", synced, nodes.len());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read system uptime from /proc/uptime (seconds since boot).
|
/// Read system uptime from /proc/uptime (seconds since boot).
|
||||||
async fn read_uptime() -> Result<f64> {
|
pub(super) async fn read_uptime() -> Result<f64> {
|
||||||
let content = tokio::fs::read_to_string("/proc/uptime")
|
let content = tokio::fs::read_to_string("/proc/uptime")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/uptime")?;
|
.context("Failed to read /proc/uptime")?;
|
||||||
@@ -187,7 +56,7 @@ async fn read_uptime() -> Result<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read load averages from /proc/loadavg.
|
/// Read load averages from /proc/loadavg.
|
||||||
async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
pub(super) async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
||||||
let content = tokio::fs::read_to_string("/proc/loadavg")
|
let content = tokio::fs::read_to_string("/proc/loadavg")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/loadavg")?;
|
.context("Failed to read /proc/loadavg")?;
|
||||||
@@ -211,7 +80,7 @@ async fn read_loadavg() -> Result<(f64, f64, f64)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute CPU usage by sampling /proc/stat twice with a 250ms gap.
|
/// Compute CPU usage by sampling /proc/stat twice with a 250ms gap.
|
||||||
async fn read_cpu_usage() -> Result<f64> {
|
pub(super) async fn read_cpu_usage() -> Result<f64> {
|
||||||
let snap1 = read_cpu_jiffies().await?;
|
let snap1 = read_cpu_jiffies().await?;
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
let snap2 = read_cpu_jiffies().await?;
|
let snap2 = read_cpu_jiffies().await?;
|
||||||
@@ -256,7 +125,7 @@ async fn read_cpu_jiffies() -> Result<CpuJiffies> {
|
|||||||
|
|
||||||
/// Read memory info from /proc/meminfo.
|
/// Read memory info from /proc/meminfo.
|
||||||
/// Returns (used_bytes, total_bytes).
|
/// Returns (used_bytes, total_bytes).
|
||||||
async fn read_meminfo() -> Result<(u64, u64)> {
|
pub(super) async fn read_meminfo() -> Result<(u64, u64)> {
|
||||||
let content = tokio::fs::read_to_string("/proc/meminfo")
|
let content = tokio::fs::read_to_string("/proc/meminfo")
|
||||||
.await
|
.await
|
||||||
.context("Failed to read /proc/meminfo")?;
|
.context("Failed to read /proc/meminfo")?;
|
||||||
@@ -277,7 +146,7 @@ async fn read_meminfo() -> Result<(u64, u64)> {
|
|||||||
Ok((used_bytes, total_bytes))
|
Ok((used_bytes, total_bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||||
val.trim()
|
val.trim()
|
||||||
.trim_end_matches("kB")
|
.trim_end_matches("kB")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -287,7 +156,7 @@ fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
|||||||
|
|
||||||
/// Read disk usage via `df` for the root filesystem.
|
/// Read disk usage via `df` for the root filesystem.
|
||||||
/// Returns (used_bytes, total_bytes).
|
/// Returns (used_bytes, total_bytes).
|
||||||
async fn read_disk_usage() -> Result<(u64, u64)> {
|
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||||
let output = tokio::process::Command::new("df")
|
let output = tokio::process::Command::new("df")
|
||||||
.args(["--block-size=1", "--output=used,size", "/"])
|
.args(["--block-size=1", "--output=used,size", "/"])
|
||||||
.output()
|
.output()
|
||||||
@@ -320,7 +189,7 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read top 10 processes by CPU from `ps`.
|
/// Read top 10 processes by CPU from `ps`.
|
||||||
async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
pub(super) async fn read_top_processes() -> Result<Vec<serde_json::Value>> {
|
||||||
let output = tokio::process::Command::new("ps")
|
let output = tokio::process::Command::new("ps")
|
||||||
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
.args(["--no-headers", "-eo", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
||||||
.output()
|
.output()
|
||||||
@@ -362,7 +231,7 @@ const KNOWN_HW_WALLETS: &[(u16, &str)] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
|
/// Scan /sys/bus/usb/devices/ for known hardware wallet vendor IDs.
|
||||||
async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||||
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
let usb_dir = std::path::Path::new("/sys/bus/usb/devices");
|
||||||
if !usb_dir.exists() {
|
if !usb_dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -424,7 +293,7 @@ async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
|||||||
|
|
||||||
/// Prune dangling container images via `podman image prune -f`.
|
/// Prune dangling container images via `podman image prune -f`.
|
||||||
/// Returns estimated bytes freed.
|
/// Returns estimated bytes freed.
|
||||||
async fn prune_container_images() -> Result<u64> {
|
pub(super) async fn prune_container_images() -> Result<u64> {
|
||||||
let output = tokio::process::Command::new("podman")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["image", "prune", "-f"])
|
.args(["image", "prune", "-f"])
|
||||||
.output()
|
.output()
|
||||||
@@ -445,7 +314,7 @@ async fn prune_container_images() -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prune container build cache via `podman system prune -f`.
|
/// Prune container build cache via `podman system prune -f`.
|
||||||
async fn prune_build_cache() -> Result<u64> {
|
pub(super) async fn prune_build_cache() -> Result<u64> {
|
||||||
// Just prune volumes and build cache (not containers or images — those are handled above)
|
// Just prune volumes and build cache (not containers or images — those are handled above)
|
||||||
let output = tokio::process::Command::new("podman")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["volume", "prune", "-f"])
|
.args(["volume", "prune", "-f"])
|
||||||
@@ -466,7 +335,7 @@ async fn prune_build_cache() -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean log files older than `max_age_days` from common log directories.
|
/// Clean log files older than `max_age_days` from common log directories.
|
||||||
async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
||||||
let output = tokio::process::Command::new("sudo")
|
let output = tokio::process::Command::new("sudo")
|
||||||
.args([
|
.args([
|
||||||
"find",
|
"find",
|
||||||
@@ -506,7 +375,7 @@ async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove stale temp files from /tmp and /var/tmp.
|
/// Remove stale temp files from /tmp and /var/tmp.
|
||||||
async fn clean_temp_files() -> Result<u64> {
|
pub(super) async fn clean_temp_files() -> Result<u64> {
|
||||||
let mut freed = 0u64;
|
let mut freed = 0u64;
|
||||||
|
|
||||||
for dir in &["/tmp", "/var/tmp"] {
|
for dir in &["/tmp", "/var/tmp"] {
|
||||||
@@ -534,7 +403,7 @@ async fn clean_temp_files() -> Result<u64> {
|
|||||||
Ok(freed)
|
Ok(freed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_bytes(bytes: u64) -> String {
|
pub(super) fn format_bytes(bytes: u64) -> String {
|
||||||
const KB: u64 = 1024;
|
const KB: u64 = 1024;
|
||||||
const MB: u64 = KB * 1024;
|
const MB: u64 = KB * 1024;
|
||||||
const GB: u64 = MB * 1024;
|
const GB: u64 = MB * 1024;
|
||||||
@@ -551,7 +420,7 @@ fn format_bytes(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
|
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
|
||||||
async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||||
let mut temps = Vec::new();
|
let mut temps = Vec::new();
|
||||||
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
let thermal_dir = std::path::Path::new("/sys/class/thermal");
|
||||||
if !thermal_dir.exists() {
|
if !thermal_dir.exists() {
|
||||||
@@ -590,91 +459,3 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
|||||||
|
|
||||||
Ok(temps)
|
Ok(temps)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
|
||||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
|
||||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
|
||||||
pub(super) async fn handle_system_factory_reset(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
// Safety check: require { confirm: true }
|
|
||||||
let confirmed = params
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.get("confirm"))
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !confirmed {
|
|
||||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
|
||||||
|
|
||||||
let data_dir = &self.config.data_dir;
|
|
||||||
|
|
||||||
// 1. Stop and remove ALL containers (force)
|
|
||||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
|
||||||
if let Ok(containers) = client.list_containers().await {
|
|
||||||
for c in &containers {
|
|
||||||
tracing::info!("Factory reset: removing container {}", c.name);
|
|
||||||
let _ = client.stop_container(&c.name).await;
|
|
||||||
let _ = client.remove_container(&c.name).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Remove all container images
|
|
||||||
tracing::info!("Factory reset: pruning all container images");
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["rmi", "--all", "--force"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 3. Prune volumes and build cache
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["volume", "prune", "-f"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["system", "prune", "-af"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 4. Wipe the entire data directory contents
|
|
||||||
// Delete everything inside data_dir, then recreate the empty dir.
|
|
||||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
|
||||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
||||||
let path = entry.path();
|
|
||||||
let name = entry.file_name();
|
|
||||||
let name_str = name.to_string_lossy();
|
|
||||||
|
|
||||||
// Skip the tor directory (managed by system debian-tor user)
|
|
||||||
if name_str == "tor" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Factory reset: removing {}", path.display());
|
|
||||||
if path.is_dir() {
|
|
||||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
|
||||||
} else {
|
|
||||||
let _ = tokio::fs::remove_file(&path).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Clear all sessions
|
|
||||||
self.session_store.invalidate_all_except("").await;
|
|
||||||
|
|
||||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
|
||||||
|
|
||||||
// Restart the service via systemd
|
|
||||||
tokio::spawn(async {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
||||||
let _ = std::process::Command::new("sudo")
|
|
||||||
.args(["systemctl", "restart", "archipelago"])
|
|
||||||
.spawn();
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "status": "resetting" }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use crate::{federation, identity};
|
|
||||||
|
|
||||||
const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor";
|
|
||||||
const SERVICES_CONFIG: &str = "services.json";
|
|
||||||
/// How long old service directories are kept during transition (seconds).
|
|
||||||
const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct TorService {
|
|
||||||
name: String,
|
|
||||||
local_port: u16,
|
|
||||||
onion_address: Option<String>,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
||||||
struct ServicesConfig {
|
|
||||||
services: Vec<TorServiceEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct TorServiceEntry {
|
|
||||||
name: String,
|
|
||||||
local_port: u16,
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RpcHandler {
|
|
||||||
/// List all configured hidden services with their .onion addresses.
|
|
||||||
pub(super) async fn handle_tor_list_services(
|
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
|
||||||
let services = list_services(&config_dir).await?;
|
|
||||||
Ok(serde_json::json!({ "services": services }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new hidden service for a given local port.
|
|
||||||
pub(super) async fn handle_tor_create_service(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
|
||||||
let local_port = params
|
|
||||||
.get("local_port")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing local_port"))? as u16;
|
|
||||||
|
|
||||||
// Validate name
|
|
||||||
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
|
||||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
|
||||||
let mut config = load_services_config(&config_dir).await;
|
|
||||||
if config.services.iter().any(|s| s.name == name) {
|
|
||||||
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
|
||||||
}
|
|
||||||
|
|
||||||
config.services.push(TorServiceEntry {
|
|
||||||
name: name.to_string(),
|
|
||||||
local_port,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
save_services_config(&config_dir, &config).await?;
|
|
||||||
|
|
||||||
debug!("Tor service created: {} -> port {}", name, local_port);
|
|
||||||
Ok(serde_json::json!({ "created": true, "name": name }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a hidden service.
|
|
||||||
pub(super) async fn handle_tor_delete_service(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
|
||||||
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
|
||||||
let mut config = load_services_config(&config_dir).await;
|
|
||||||
let before = config.services.len();
|
|
||||||
config.services.retain(|s| s.name != name);
|
|
||||||
if config.services.len() == before {
|
|
||||||
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
|
||||||
}
|
|
||||||
save_services_config(&config_dir, &config).await?;
|
|
||||||
|
|
||||||
debug!("Tor service deleted: {}", name);
|
|
||||||
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the .onion address for a specific service.
|
|
||||||
pub(super) async fn handle_tor_get_onion_address(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
|
||||||
|
|
||||||
let onion = read_onion_address(name);
|
|
||||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate a hidden service's .onion address by generating a new keypair.
|
|
||||||
/// The old service directory is renamed for a 24h transition period.
|
|
||||||
pub(super) async fn handle_tor_rotate_service(
|
|
||||||
&self,
|
|
||||||
params: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
||||||
let name = params
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
|
||||||
|
|
||||||
let base = tor_data_dir();
|
|
||||||
let service_dir = format!("{}/hidden_service_{}", base, name);
|
|
||||||
|
|
||||||
// Read old .onion address before rotation
|
|
||||||
let old_onion = read_onion_address(name);
|
|
||||||
if old_onion.is_none() {
|
|
||||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old service directory immediately — no transition period
|
|
||||||
let delete_status = tokio::process::Command::new("sudo")
|
|
||||||
.args(["rm", "-rf", &service_dir])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.context("Failed to delete hidden service directory")?;
|
|
||||||
|
|
||||||
if !delete_status.success() {
|
|
||||||
return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the readable tor-hostnames cache so wait_for_hostname reads the new key
|
|
||||||
let hostnames_dir = std::path::Path::new(&base)
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
|
||||||
.join("tor-hostnames");
|
|
||||||
let _ = tokio::fs::remove_file(hostnames_dir.join(name)).await;
|
|
||||||
|
|
||||||
info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting Tor");
|
|
||||||
|
|
||||||
// Try system Tor first (hidden services may be in /etc/tor/torrc), then container
|
|
||||||
let system_ok = tokio::process::Command::new("sudo")
|
|
||||||
.args(["systemctl", "restart", "tor"])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !system_ok {
|
|
||||||
// Fall back to container restart
|
|
||||||
let container_ok = tokio::process::Command::new("podman")
|
|
||||||
.args(["restart", "archy-tor"])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !container_ok {
|
|
||||||
warn!("Failed to restart Tor after rotation — old address already destroyed");
|
|
||||||
return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait up to 60s for new hostname file to appear
|
|
||||||
let new_onion = wait_for_hostname(name, 60).await;
|
|
||||||
|
|
||||||
// Update the readable tor-hostnames copy
|
|
||||||
if let Some(ref new_addr) = new_onion {
|
|
||||||
let hostnames_dir = std::path::Path::new(&base)
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
|
||||||
.join("tor-hostnames");
|
|
||||||
if let Err(e) = tokio::fs::create_dir_all(&hostnames_dir).await {
|
|
||||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
|
||||||
}
|
|
||||||
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), new_addr).await {
|
|
||||||
warn!("Failed to update tor-hostnames copy: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify federation peers of address change (private peer-to-peer, no public relays)
|
|
||||||
if let Some(ref new_addr) = new_onion {
|
|
||||||
let data_dir = self.config.data_dir.clone();
|
|
||||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
|
||||||
let new_addr_clone = new_addr.clone();
|
|
||||||
let old_onion_clone = old_onion.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
notify_federation_peers_address_change(
|
|
||||||
&data_dir,
|
|
||||||
&new_addr_clone,
|
|
||||||
old_onion_clone.as_deref(),
|
|
||||||
tor_proxy.as_deref(),
|
|
||||||
).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"rotated": true,
|
|
||||||
"name": name,
|
|
||||||
"old_onion": old_onion,
|
|
||||||
"new_onion": new_onion,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up expired rotated service directories past the transition period.
|
|
||||||
pub(super) async fn handle_tor_cleanup_rotated(
|
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let base = tor_data_dir();
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let mut cleaned = Vec::new();
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if !name.contains("_old_") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Parse timestamp from suffix: hidden_service_NAME_old_TIMESTAMP
|
|
||||||
if let Some(ts_str) = name.rsplit('_').next() {
|
|
||||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
|
||||||
if now - ts > ROTATION_TRANSITION_SECS {
|
|
||||||
let path = entry.path();
|
|
||||||
let status = tokio::process::Command::new("sudo")
|
|
||||||
.args(["rm", "-rf", &path.to_string_lossy()])
|
|
||||||
.status()
|
|
||||||
.await;
|
|
||||||
if status.map(|s| s.success()).unwrap_or(false) {
|
|
||||||
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
|
||||||
cleaned.push(name);
|
|
||||||
} else {
|
|
||||||
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle Tor access for a specific app (enable/disable).
|
|
||||||
pub(super) async fn handle_tor_toggle_app(
|
|
||||||
&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("app_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
|
||||||
let enabled = params
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
|
||||||
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
|
||||||
let mut config = load_services_config(&config_dir).await;
|
|
||||||
|
|
||||||
// Find the service entry for this app
|
|
||||||
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
|
||||||
match found {
|
|
||||||
Some(entry) => {
|
|
||||||
if entry.enabled == enabled {
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"app_id": app_id,
|
|
||||||
"enabled": enabled,
|
|
||||||
"changed": false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
entry.enabled = enabled;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if !enabled {
|
|
||||||
// Nothing to disable — doesn't exist
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"app_id": app_id,
|
|
||||||
"enabled": false,
|
|
||||||
"changed": false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Add new entry
|
|
||||||
let port = known_service_port(app_id);
|
|
||||||
config.services.push(TorServiceEntry {
|
|
||||||
name: app_id.to_string(),
|
|
||||||
local_port: port,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
save_services_config(&config_dir, &config).await?;
|
|
||||||
|
|
||||||
let base = tor_data_dir();
|
|
||||||
let service_dir = format!("{}/hidden_service_{}", base, app_id);
|
|
||||||
|
|
||||||
if !enabled {
|
|
||||||
// Remove the hidden service directory so Tor stops serving it
|
|
||||||
let _ = tokio::process::Command::new("sudo")
|
|
||||||
.args(["rm", "-rf", &service_dir])
|
|
||||||
.status()
|
|
||||||
.await;
|
|
||||||
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart Tor to apply changes — try system service first, then container
|
|
||||||
let system_ok = tokio::process::Command::new("sudo")
|
|
||||||
.args(["systemctl", "restart", "tor"])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !system_ok {
|
|
||||||
let container_ok = tokio::process::Command::new("podman")
|
|
||||||
.args(["restart", "archy-tor"])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !container_ok {
|
|
||||||
warn!("Failed to restart Tor after toggle");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If enabling, wait for hostname to appear
|
|
||||||
let new_onion = if enabled {
|
|
||||||
wait_for_hostname(app_id, 60).await
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"app_id": app_id,
|
|
||||||
"enabled": enabled,
|
|
||||||
"changed": true,
|
|
||||||
"onion_address": new_onion,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all hidden services by scanning the filesystem and merging with config.
|
|
||||||
async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
|
||||||
let base = tor_data_dir();
|
|
||||||
let config = load_services_config(config_dir).await;
|
|
||||||
let mut services = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
// First, add services from config
|
|
||||||
for entry in &config.services {
|
|
||||||
let onion = read_onion_address(&entry.name);
|
|
||||||
seen.insert(entry.name.clone());
|
|
||||||
services.push(TorService {
|
|
||||||
name: entry.name.clone(),
|
|
||||||
local_port: entry.local_port,
|
|
||||||
onion_address: onion,
|
|
||||||
enabled: entry.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then, scan filesystem for any hidden_service_* dirs not in config
|
|
||||||
// Check both /var/lib/tor/ and /var/lib/archipelago/tor/
|
|
||||||
for scan_dir in ["/var/lib/tor", &base] {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(scan_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
|
||||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
|
||||||
if seen.contains(&service_name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let onion = read_onion_address(&service_name);
|
|
||||||
let port = known_service_port(&service_name);
|
|
||||||
seen.insert(service_name.clone());
|
|
||||||
services.push(TorService {
|
|
||||||
name: service_name,
|
|
||||||
local_port: port,
|
|
||||||
onion_address: onion,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(services)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read .onion address from hostname file.
|
|
||||||
/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/.
|
|
||||||
fn read_onion_address(service_name: &str) -> Option<String> {
|
|
||||||
let base = tor_data_dir();
|
|
||||||
let base_path = std::path::Path::new(&base);
|
|
||||||
|
|
||||||
// Try readable hostname copy first (system Tor owns hidden_service dirs at 0700)
|
|
||||||
let hostnames_dir = base_path
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
|
|
||||||
.join("tor-hostnames")
|
|
||||||
.join(service_name);
|
|
||||||
if let Some(addr) = std::fs::read_to_string(&hostnames_dir)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
|
|
||||||
{
|
|
||||||
return Some(addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/
|
|
||||||
let search_bases = [
|
|
||||||
std::path::PathBuf::from("/var/lib/tor"),
|
|
||||||
base_path.to_path_buf(),
|
|
||||||
];
|
|
||||||
for search_base in &search_bases {
|
|
||||||
let path = search_base
|
|
||||||
.join(format!("hidden_service_{}", service_name))
|
|
||||||
.join("hostname");
|
|
||||||
if let Some(addr) = std::fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.or_else(|| {
|
|
||||||
std::process::Command::new("sudo")
|
|
||||||
.args(["cat", &path.to_string_lossy()])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.filter(|o| o.status.success())
|
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
||||||
})
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
|
|
||||||
{
|
|
||||||
return Some(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Known default ports for built-in services.
|
|
||||||
fn known_service_port(name: &str) -> u16 {
|
|
||||||
match name {
|
|
||||||
"archipelago" => 80,
|
|
||||||
"lnd" => 8081,
|
|
||||||
"btcpay" => 23000,
|
|
||||||
"mempool" => 4080,
|
|
||||||
"fedimint" => 8175,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tor_data_dir() -> String {
|
|
||||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
|
||||||
match tokio::fs::read_to_string(&path).await {
|
|
||||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
|
||||||
Err(_) => ServicesConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
|
||||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
|
||||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
|
||||||
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify federation peers of address change (private peer-to-peer only, never public relays).
|
|
||||||
async fn notify_federation_peers_address_change(
|
|
||||||
data_dir: &std::path::Path,
|
|
||||||
new_onion: &str,
|
|
||||||
old_onion: Option<&str>,
|
|
||||||
tor_proxy: Option<&str>,
|
|
||||||
) {
|
|
||||||
let identity_dir = data_dir.join("identity");
|
|
||||||
match identity::NodeIdentity::load_or_create(&identity_dir).await {
|
|
||||||
Ok(node_id) => {
|
|
||||||
let did = node_id.did_key();
|
|
||||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
|
||||||
match federation::load_nodes(data_dir).await {
|
|
||||||
Ok(peers) => {
|
|
||||||
for peer in peers {
|
|
||||||
if peer.onion.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"method": "federation.peer-address-changed",
|
|
||||||
"params": {
|
|
||||||
"did": did,
|
|
||||||
"new_onion": new_onion,
|
|
||||||
"old_onion": old_onion,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
|
||||||
let client = match reqwest::Client::builder()
|
|
||||||
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
|
|
||||||
.or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050")) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => continue,
|
|
||||||
})
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
match client.post(&url).json(&payload).send().await {
|
|
||||||
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
|
|
||||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to load federation peers: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to load node identity for propagation: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait for a hostname file to appear after Tor restart (up to max_secs).
|
|
||||||
async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
|
||||||
for _ in 0..max_secs {
|
|
||||||
if let Some(addr) = read_onion_address(service_name) {
|
|
||||||
return Some(addr);
|
|
||||||
}
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
warn!(service = service_name, "Timed out waiting for new .onion hostname");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
339
core/archipelago/src/api/rpc/tor/handlers.rs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// List all configured hidden services with their .onion addresses.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_list_services(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let services = list_services(&config_dir).await?;
|
||||||
|
let tor_running = check_tor_running().await;
|
||||||
|
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new hidden service for a given local port.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_create_service(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
let raw_port = params
|
||||||
|
.get("local_port")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
let remote_port = params
|
||||||
|
.get("remote_port")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as u16);
|
||||||
|
|
||||||
|
validate_service_name(name)?;
|
||||||
|
|
||||||
|
let local_port = if raw_port == 0 {
|
||||||
|
let detected = known_service_port(name);
|
||||||
|
if detected == 0 {
|
||||||
|
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
|
||||||
|
}
|
||||||
|
detected
|
||||||
|
} else {
|
||||||
|
raw_port
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let mut config = load_services_config(&config_dir).await;
|
||||||
|
if config.services.iter().any(|s| s.name == name) {
|
||||||
|
return Err(anyhow::anyhow!("Service '{}' already exists", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_proto = is_protocol_service(name);
|
||||||
|
config.services.push(TorServiceEntry {
|
||||||
|
name: name.to_string(),
|
||||||
|
local_port,
|
||||||
|
remote_port,
|
||||||
|
unauthenticated: is_proto,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
save_services_config(&config_dir, &config).await?;
|
||||||
|
|
||||||
|
regenerate_torrc(&config).await?;
|
||||||
|
restart_tor().await?;
|
||||||
|
|
||||||
|
let onion = wait_for_hostname(name, 60).await;
|
||||||
|
if let Some(ref addr) = onion {
|
||||||
|
sync_single_hostname(name, addr).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(service = name, port = local_port, "Created Tor hidden service");
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"created": true,
|
||||||
|
"name": name,
|
||||||
|
"onion_address": onion,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a hidden service.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_delete_service(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
validate_service_name(name)?;
|
||||||
|
|
||||||
|
if name == "archipelago" {
|
||||||
|
return Err(anyhow::anyhow!("Cannot delete the node's own Tor service"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let mut config = load_services_config(&config_dir).await;
|
||||||
|
let before = config.services.len();
|
||||||
|
config.services.retain(|s| s.name != name);
|
||||||
|
if config.services.len() == before {
|
||||||
|
return Err(anyhow::anyhow!("Service '{}' not found", name));
|
||||||
|
}
|
||||||
|
save_services_config(&config_dir, &config).await?;
|
||||||
|
|
||||||
|
delete_hidden_service_dir(name).await;
|
||||||
|
|
||||||
|
regenerate_torrc(&config).await?;
|
||||||
|
restart_tor().await?;
|
||||||
|
|
||||||
|
info!(service = name, "Deleted Tor hidden service");
|
||||||
|
Ok(serde_json::json!({ "deleted": true, "name": name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the .onion address for a specific service.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_get_onion_address(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
validate_service_name(name)?;
|
||||||
|
|
||||||
|
let onion = read_onion_address(name).await;
|
||||||
|
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate a hidden service's .onion address by generating a new keypair.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_rotate_service(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
validate_service_name(name)?;
|
||||||
|
|
||||||
|
let old_onion = read_onion_address(name).await;
|
||||||
|
if old_onion.is_none() {
|
||||||
|
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
rename_hidden_service_dir(name, timestamp).await;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
service = name,
|
||||||
|
old_onion = ?old_onion,
|
||||||
|
"Renamed old Tor service dir — restarting Tor to generate new keypair"
|
||||||
|
);
|
||||||
|
|
||||||
|
restart_tor().await?;
|
||||||
|
|
||||||
|
let new_onion = wait_for_hostname(name, 60).await;
|
||||||
|
|
||||||
|
if let Some(ref new_addr) = new_onion {
|
||||||
|
sync_single_hostname(name, new_addr).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_name = format!("{}_old_{}", name, timestamp);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||||
|
info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir");
|
||||||
|
delete_hidden_service_dir(&old_name).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref new_addr) = new_onion {
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||||
|
let new_addr_clone = new_addr.clone();
|
||||||
|
let old_onion_clone = old_onion.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
notify_federation_peers_address_change(
|
||||||
|
&data_dir,
|
||||||
|
&new_addr_clone,
|
||||||
|
old_onion_clone.as_deref(),
|
||||||
|
tor_proxy.as_deref(),
|
||||||
|
).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"rotated": true,
|
||||||
|
"name": name,
|
||||||
|
"old_onion": old_onion,
|
||||||
|
"new_onion": new_onion,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired rotated service directories past the transition period.
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_cleanup_rotated(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let base = detect_hidden_service_base();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let mut cleaned = Vec::new();
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&base).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.contains("_old_") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ts_str) = name.rsplit('_').next() {
|
||||||
|
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||||
|
if now - ts > ROTATION_TRANSITION_SECS {
|
||||||
|
let path = entry.path();
|
||||||
|
let status = tokio::process::Command::new("sudo")
|
||||||
|
.args(["rm", "-rf", &path.to_string_lossy()])
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
if status.map(|s| s.success()).unwrap_or(false) {
|
||||||
|
info!(dir = %name, "Cleaned up expired rotated Tor service");
|
||||||
|
cleaned.push(name);
|
||||||
|
} else {
|
||||||
|
warn!(dir = %name, "Failed to clean up rotated Tor service");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle Tor access for a specific app (enable/disable).
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_toggle_app(
|
||||||
|
&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("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
|
||||||
|
validate_service_name(app_id)?;
|
||||||
|
|
||||||
|
let enabled = params
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?;
|
||||||
|
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let mut config = load_services_config(&config_dir).await;
|
||||||
|
|
||||||
|
let found = config.services.iter_mut().find(|s| s.name == app_id);
|
||||||
|
match found {
|
||||||
|
Some(entry) => {
|
||||||
|
if entry.enabled == enabled {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"enabled": enabled,
|
||||||
|
"changed": false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
entry.enabled = enabled;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if !enabled {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"enabled": false,
|
||||||
|
"changed": false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let port = known_service_port(app_id);
|
||||||
|
let is_proto = is_protocol_service(app_id);
|
||||||
|
config.services.push(TorServiceEntry {
|
||||||
|
name: app_id.to_string(),
|
||||||
|
local_port: port,
|
||||||
|
remote_port: None,
|
||||||
|
unauthenticated: is_proto,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save_services_config(&config_dir, &config).await?;
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
delete_hidden_service_dir(app_id).await;
|
||||||
|
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerate_torrc(&config).await?;
|
||||||
|
restart_tor().await?;
|
||||||
|
|
||||||
|
let new_onion = if enabled {
|
||||||
|
let onion = wait_for_hostname(app_id, 60).await;
|
||||||
|
if let Some(ref addr) = onion {
|
||||||
|
sync_single_hostname(app_id, addr).await;
|
||||||
|
}
|
||||||
|
onion
|
||||||
|
} else {
|
||||||
|
let hostnames_dir = self.config.data_dir.join("tor-hostnames");
|
||||||
|
let _ = tokio::fs::remove_file(hostnames_dir.join(app_id)).await;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"enabled": enabled,
|
||||||
|
"changed": true,
|
||||||
|
"onion_address": new_onion,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart Tor daemon (system or container).
|
||||||
|
pub(in crate::api::rpc) async fn handle_tor_restart(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
info!("Manual Tor restart requested");
|
||||||
|
|
||||||
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
let config = load_services_config(&config_dir).await;
|
||||||
|
regenerate_torrc(&config).await?;
|
||||||
|
|
||||||
|
restart_tor().await?;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
sync_all_hostname_copies(&config).await;
|
||||||
|
|
||||||
|
let running = check_tor_running().await;
|
||||||
|
Ok(serde_json::json!({ "restarted": true, "tor_running": running }))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user