From 1073d9fd2c695ee9e8bd53ad308488edd55ba9e7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 17 Feb 2026 15:03:34 +0000 Subject: [PATCH] Update Fedimint configuration and enhance onboarding process - Upgraded Fedimint version to v0.10.0 in docker-compose.yml and manifest.yml, adding support for the built-in Guardian UI. - Modified .gitignore to exclude deploy-config.sh script. - Enhanced onboarding process in AuthManager to persist onboarding state and validate password strength during user setup. - Updated API to handle onboarding completion and password change requests, ensuring a smoother user experience. - Improved configuration management to support Nostr discovery and Tor proxy settings, enhancing node identity features. --- .cursor/rules/Development-Workflow.mdc | 88 +- .gitignore | 1 + apps/fedimint/manifest.yml | 27 +- core/Cargo.lock | 749 +++++++++++++++++- core/archipelago/Cargo.toml | 14 +- core/archipelago/src/api/handler.rs | 43 +- core/archipelago/src/api/rpc.rs | 649 ++++++++++++--- core/archipelago/src/auth.rs | 151 +++- core/archipelago/src/config.rs | 55 ++ .../src/container/docker_packages.rs | 74 +- core/archipelago/src/data_model.rs | 6 + core/archipelago/src/electrs_status.rs | 158 ++++ core/archipelago/src/identity.rs | 122 +++ core/archipelago/src/main.rs | 6 + core/archipelago/src/node_message.rs | 133 ++++ core/archipelago/src/nostr_discovery.rs | 345 ++++++++ core/archipelago/src/peers.rs | 63 ++ core/archipelago/src/port_allocator.rs | 148 ++++ core/archipelago/src/server.rs | 115 ++- docker-compose.yml | 15 +- docker/electrs-ui/Dockerfile | 10 + docker/electrs-ui/index.html | 145 ++++ docker/electrs-ui/nginx.conf | 19 + docker/lnd-ui/nginx.conf | 2 +- docs/SECURITY-NOSTR-DISCOVERY.md | 42 + docs/WEB5_NOSTR_IDENTITY.md | 68 ++ .../archipelago-scripts/archipelago-menu.sh | 4 + image-recipe/build-auto-installer-iso.sh | 176 +++- image-recipe/configs/archipelago.service | 3 + image-recipe/configs/nginx-archipelago.conf | 8 + neode-ui/dev-dist/sw.js | 2 +- neode-ui/mock-backend.js | 22 +- neode-ui/package-lock.json | 10 + neode-ui/package.json | 1 + .../public/assets/img/app-icons/README.md | 11 + .../public/assets/img/app-icons/electrs.svg | 12 + .../{icon-fedimint.jpeg => fedimint.png} | Bin neode-ui/scripts/download-app-icons.js | 65 +- neode-ui/src/App.vue | 82 +- neode-ui/src/api/rpc-client.ts | 122 +++ neode-ui/src/components/AnimatedLogo.vue | 109 +++ .../src/components/ControllerIndicator.vue | 21 + neode-ui/src/components/HelpGuideModal.vue | 58 ++ neode-ui/src/components/SpotlightSearch.vue | 361 +++++++++ neode-ui/src/composables/useControllerNav.ts | 170 ++++ neode-ui/src/composables/useMessageToast.ts | 86 ++ neode-ui/src/composables/useOnboarding.ts | 20 + neode-ui/src/data/helpTree.ts | 97 +++ neode-ui/src/router/index.ts | 25 +- neode-ui/src/stores/app.ts | 1 + neode-ui/src/stores/controller.ts | 23 + neode-ui/src/stores/spotlight.ts | 99 +++ neode-ui/src/style.css | 44 +- neode-ui/src/types/api.ts | 3 + neode-ui/src/utils/dummyApps.ts | 4 +- neode-ui/src/views/AppDetails.vue | 103 ++- neode-ui/src/views/Apps.vue | 6 +- neode-ui/src/views/Dashboard.vue | 52 +- neode-ui/src/views/Home.vue | 32 +- neode-ui/src/views/Marketplace.vue | 62 +- neode-ui/src/views/OnboardingDid.vue | 48 +- neode-ui/src/views/OnboardingOptions.vue | 9 +- neode-ui/src/views/OnboardingVerify.vue | 22 +- neode-ui/src/views/RootRedirect.vue | 30 + neode-ui/src/views/Server.vue | 58 +- neode-ui/src/views/Settings.vue | 244 +++++- neode-ui/src/views/Web5.vue | 390 ++++++++- scripts/deploy-config.example | 7 + scripts/deploy-to-target.sh | 341 +++++++- scripts/nginx-archipelago-patch.conf | 6 + scripts/tor/README.md | 20 + scripts/tor/torrc.template | 29 + start-docker-apps.sh | 2 +- 73 files changed, 5870 insertions(+), 478 deletions(-) create mode 100644 core/archipelago/src/electrs_status.rs create mode 100644 core/archipelago/src/identity.rs create mode 100644 core/archipelago/src/node_message.rs create mode 100644 core/archipelago/src/nostr_discovery.rs create mode 100644 core/archipelago/src/peers.rs create mode 100644 core/archipelago/src/port_allocator.rs create mode 100644 docker/electrs-ui/Dockerfile create mode 100644 docker/electrs-ui/index.html create mode 100644 docker/electrs-ui/nginx.conf create mode 100644 docs/SECURITY-NOSTR-DISCOVERY.md create mode 100644 docs/WEB5_NOSTR_IDENTITY.md create mode 100644 neode-ui/public/assets/img/app-icons/README.md create mode 100644 neode-ui/public/assets/img/app-icons/electrs.svg rename neode-ui/public/assets/img/app-icons/{icon-fedimint.jpeg => fedimint.png} (100%) create mode 100644 neode-ui/src/components/AnimatedLogo.vue create mode 100644 neode-ui/src/components/ControllerIndicator.vue create mode 100644 neode-ui/src/components/HelpGuideModal.vue create mode 100644 neode-ui/src/components/SpotlightSearch.vue create mode 100644 neode-ui/src/composables/useControllerNav.ts create mode 100644 neode-ui/src/composables/useMessageToast.ts create mode 100644 neode-ui/src/composables/useOnboarding.ts create mode 100644 neode-ui/src/data/helpTree.ts create mode 100644 neode-ui/src/stores/controller.ts create mode 100644 neode-ui/src/stores/spotlight.ts create mode 100644 neode-ui/src/views/RootRedirect.vue create mode 100644 scripts/deploy-config.example create mode 100644 scripts/nginx-archipelago-patch.conf create mode 100644 scripts/tor/README.md create mode 100644 scripts/tor/torrc.template diff --git a/.cursor/rules/Development-Workflow.mdc b/.cursor/rules/Development-Workflow.mdc index 1fbaade1..4831d917 100644 --- a/.cursor/rules/Development-Workflow.mdc +++ b/.cursor/rules/Development-Workflow.mdc @@ -5,10 +5,24 @@ alwaysApply: true # Archipelago Development Workflow +## Priority: Deploy-Test-Fix Loop + +**This is the primary workflow. Follow it for every change.** + +1. **Make the change** the user requests +2. **SSH and build to live server** - Run `./scripts/deploy-to-target.sh --live` once done +3. **Test that it works** - Verify apps launch and show their UI in a new browser tab on their server port (e.g. http://192.168.1.228:4080 for Mempool) +4. **If broken, fix and repeat** - Debug, fix, redeploy, and test again until complete +5. **End loop** only when everything works + +Do not leave deployment or testing to the user. The agent has SSH access to perform all building and work on the live server. + ## Deployment Strategy **Always deploy to live system for testing** - The target device (192.168.1.228) is a development machine, so deploy changes directly to the live system rather than using dev servers. +**When making changes, always run deploy** - After editing code (frontend, backend, scripts, or configs), run `./scripts/deploy-to-target.sh --live` to sync, build, and deploy. Do not leave the user to deploy manually. + ### Backend: build on server via rsync (never on macOS) - **Always** deploy backend by: (1) rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then (2) SSH and run `cargo build --release` on the server, then copy binary to `/usr/local/bin/` and restart `archipelago.service`. - Use `sshpass -p "archipelago"` for non-interactive rsync/SSH. **Action these builds** when making backend changes; do not build the Rust binary on macOS and copy it (causes Exec format error on Linux). @@ -27,9 +41,17 @@ This command: - Backend: `/usr/local/bin/archipelago` 4. Restarts services (systemd + nginx) +### Deploy to Both Servers + +```bash +./scripts/deploy-to-target.sh --both +``` + +Deploys to 192.168.1.228 first (builds there), then copies binary and web-ui to 192.168.1.198 (which has no rsync/cargo). + ### Target Environment -- **Host**: archipelago@192.168.1.228 +- **Host**: archipelago@192.168.1.228 (primary), archipelago@192.168.1.198 (secondary) - **OS**: Debian-based server - **Container Runtime**: Podman (root context for system services) - **Web Server**: Nginx @@ -60,12 +82,33 @@ The deployment scripts require SSH key authentication. If you encounter `Permiss - Systemd service: `/etc/systemd/system/archipelago.service` - Nginx config: `/etc/nginx/sites-available/archipelago` +## App Icons + +**Single source of truth**: `neode-ui/public/assets/img/app-icons/` + +- All app icons live here. Do not duplicate icons elsewhere. +- Naming: `{app-id}.{png|webp|svg}` (e.g. `fedimint.png`, `mempool.webp`) +- References use `/assets/img/app-icons/{filename}`. Build outputs copy from this folder. +- See `neode-ui/public/assets/img/app-icons/README.md` for details. + +## App Integration Standards + +**When adding or fixing apps, always verify end-to-end:** + +1. **Test the app UI on its port** - After getting an app working, confirm the web UI loads at its configured port (e.g. `http://192.168.1.228:4080` for Mempool). +2. **Auto-connect dependencies** - Apps must connect to their dependencies on installation: + - **Bitcoin node**: LND, Fedimint, BTCPay Server, Mempool all need Bitcoin RPC (host.containers.internal:8332 or bitcoin-knots container). + - **LND**: BTCPay Server and other Lightning apps need LND connection. +3. **Works out of the box** - After autoinstaller flash, apps should work without manual configuration. Ensure `get_app_config()` in `core/archipelago/src/api/rpc.rs` has correct env vars for each app. + ## Testing Workflow 1. Make changes locally 2. Deploy with `--live` flag 3. Test at http://192.168.1.228 -4. Check logs if needed: +4. **Verify each modified app**: Open its UI URL and confirm it loads and connects to dependencies +5. **Test with Cursor browser MCP** (when available): After app installs or fixes, use the browser MCP to open the app URL, check for console errors (502, WebSocket failures, etc.), debug, fix, redeploy, and repeat until working. +6. Check logs if needed: - Backend: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'` - Nginx: `ssh archipelago@192.168.1.228 'sudo tail -f /var/log/nginx/error.log'` 5. **Sync changes back to ISO build** (see below) @@ -82,6 +125,47 @@ Common containers: - Bitcoin Knots (ports 8332, 8333) - LND (ports 9735, 10009) +## ISO Build Debug Workflow (Flash-and-Debug) + +**Primary way to improve ISO builds.** After flashing a new machine from the ISO, SSH in and diagnose. Fix issues in the build, rebuild ISO, reflash, repeat. + +### Debug a Fresh ISO Install + +1. **Flash** the ISO to a test machine (e.g. 192.168.1.198) +2. **SSH** after first boot (same user/password as dev: `archipelago`/`archipelago`): + ```bash + ssh-keygen -R 192.168.1.198 # if host key changed after reflash + sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198 + ``` +3. **Run diagnostics** to find issues: + ```bash + # Services + systemctl is-active archipelago nginx + # Containers + sudo podman ps -a + # Tor hostname (backend needs this for peer discovery) + sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname + sudo -u archipelago cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname 2>&1 # should NOT be "Permission denied" + # Backend logs + sudo journalctl -u archipelago -n 50 + # Nginx errors + sudo tail -20 /var/log/nginx/error.log + # RPC reachable? + curl -s -X POST http://127.0.0.1:5678/rpc/v1 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{}}' + ``` +4. **Fix** issues in `image-recipe/build-auto-installer-iso.sh`, scripts, or configs +5. **Rebuild** ISO, **reflash**, **re-diagnose** until clean + +### Common ISO Issues to Check + +| Issue | Check | Fix | +|-------|-------|-----| +| Tor hostname unreadable | `sudo -u archipelago cat .../hostname` | setup-tor.sh must chmod 711 on tor dir + hidden_service_* dirs, 644 on hostname files | +| Node not discoverable | Tor hostname + Nostr publish | Fix Tor perms so node_address is set | +| RPC timeouts | nginx error.log | Increase proxy timeouts or optimize slow RPCs | +| Missing containers | `sudo podman ps -a` | ISO is minimal; apps install from marketplace | +| bitcoin-ui 404 | Port 8334 not listening | Add bitcoin-ui to first-boot or document | + ## ISO Build Integration **CRITICAL**: After testing on the live server, always update the ISO build to include your changes. diff --git a/.gitignore b/.gitignore index ab101ca1..85951eed 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ build/ .env .env.local .env.*.local +scripts/deploy-config.sh # Logs logs/ diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index 6d9db48e..c1afff92 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -1,11 +1,11 @@ app: id: fedimint name: Fedimint - version: 0.3.0 - description: Federated Bitcoin minting service. Privacy-preserving Bitcoin custody. + version: 0.10.0 + description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody. container: - image: fedimint/fedimint:0.3.0 + image: fedimint/fedimintd:v0.10.0 image_signature: cosign://... pull_policy: if-not-present @@ -28,10 +28,13 @@ app: ports: - host: 8173 container: 8173 - protocol: tcp # API + protocol: tcp # P2P - host: 8174 container: 8174 - protocol: tcp # Web UI + protocol: tcp # API + - host: 8175 + container: 8175 + protocol: tcp # Built-in Guardian UI volumes: - type: bind @@ -40,15 +43,17 @@ app: options: [rw] environment: - - FM_BITCOIND_RPC=http://bitcoin-core:8332 - - FM_BITCOIND_RPC_USER=${BITCOIN_RPC_USER} - - FM_BITCOIND_RPC_PASS=${BITCOIN_RPC_PASSWORD} - - FM_NETWORK=mainnet + - FM_DATA_DIR=/fedimint + - FM_BITCOIND_URL=http://bitcoin-core:8332 + - FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER} + - FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD} + - FM_BITCOIN_NETWORK=bitcoin + - FM_BIND_UI=0.0.0.0:8175 health_check: type: http - endpoint: http://localhost:8174 - path: /health + endpoint: http://localhost:8175 + path: / interval: 30s timeout: 5s retries: 3 diff --git a/core/Cargo.lock b/core/Cargo.lock index f7081de5..cb76691c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -36,20 +46,26 @@ dependencies = [ "archipelago-performance", "archipelago-security", "bcrypt", + "bs58", + "chrono", + "ed25519-dalek", "futures-util", + "hex", "http-body 1.0.1", "http-body-util", "hyper 0.14.32", "hyper-util", "hyper-ws-listener", + "nostr-sdk", + "rand 0.8.5", "reqwest", "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-test", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", "toml", "tower", "tower-http", @@ -72,7 +88,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -87,7 +103,7 @@ dependencies = [ "log", "serde", "serde_yaml", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -99,7 +115,7 @@ dependencies = [ "anyhow", "log", "serde", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -112,12 +128,18 @@ dependencies = [ "chrono", "log", "serde", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-trait" version = "0.1.89" @@ -129,6 +151,43 @@ dependencies = [ "syn", ] +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -153,6 +212,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bcrypt" version = "0.15.1" @@ -166,6 +231,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -187,6 +286,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -197,6 +305,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -215,6 +332,15 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.54" @@ -231,6 +357,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -253,8 +403,15 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -287,15 +444,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -304,6 +499,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -317,6 +513,37 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -361,6 +588,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -503,8 +736,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -519,6 +754,18 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.27" @@ -569,6 +816,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -743,7 +1014,7 @@ dependencies = [ "log", "sha-1", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.20.1", ] [[package]] @@ -890,9 +1161,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -965,6 +1249,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + [[package]] name = "matchers" version = "0.2.0" @@ -1014,6 +1304,89 @@ dependencies = [ "tempfile", ] +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1038,6 +1411,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -1105,6 +1484,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1123,12 +1523,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1178,8 +1599,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1189,7 +1620,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1201,6 +1642,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1271,6 +1721,7 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-socks", "tower-service", "url", "wasm-bindgen", @@ -1279,6 +1730,29 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1292,6 +1766,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -1301,6 +1789,26 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1313,6 +1821,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1328,6 +1845,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -1351,6 +1900,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1450,6 +2005,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1475,6 +2041,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.11" @@ -1507,6 +2082,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1617,7 +2202,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1631,6 +2225,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1650,6 +2255,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -1688,6 +2308,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1719,7 +2361,23 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", ] [[package]] @@ -1893,13 +2551,32 @@ dependencies = [ "http 0.2.12", "httparse", "log", - "rand", + "rand 0.8.5", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1912,12 +2589,37 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1928,6 +2630,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2064,6 +2767,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9c070b34..5c8b3cb9 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -43,12 +43,22 @@ archipelago-parmanode = { path = "../parmanode" } bcrypt = "0.15" uuid = { version = "1.0", features = ["v4"] } +# Node identity (Ed25519) +ed25519-dalek = { version = "2.1", features = ["rand_core"] } +rand = "0.8" +hex = "0.4" +bs58 = "0.5" +chrono = "0.4" + # Configuration toml = "0.8" serde_yaml = "0.9" -# HTTP client (for LND REST proxy) -reqwest = { version = "0.11", features = ["json"] } +# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging) +reqwest = { version = "0.11", features = ["json", "socks"] } + +# Nostr (node discovery) +nostr-sdk = "0.44" [dev-dependencies] tokio-test = "0.4" diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 1b52c0a9..9917165f 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -1,4 +1,6 @@ use crate::api::rpc::RpcHandler; +use crate::electrs_status; +use crate::node_message as node_msg; use crate::config::Config; use crate::state::StateManager; use anyhow::Result; @@ -20,7 +22,7 @@ pub struct ApiHandler { impl ApiHandler { pub async fn new(config: Config, state_manager: Arc) -> Result { - let rpc_handler = Arc::new(RpcHandler::new(config.clone()).await?); + let rpc_handler = Arc::new(RpcHandler::new(config.clone(), state_manager.clone()).await?); Ok(Self { _config: config, @@ -45,7 +47,7 @@ impl ApiHandler { 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)); + let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone())); debug!("{} {}", method, path); @@ -55,6 +57,10 @@ impl ApiHandler { .status(StatusCode::OK) .body(hyper::Body::from("OK")) .unwrap()), + (Method::POST, "/archipelago/node-message") => { + Self::handle_node_message(body_bytes).await + } + (Method::GET, "/electrs-status") => Self::handle_electrs_status().await, (Method::GET, path) if path.starts_with("/api/container/logs") => { Self::handle_container_logs_http(self.rpc_handler.clone(), path).await } @@ -116,6 +122,39 @@ impl ApiHandler { } } + async fn handle_node_message(body: hyper::body::Bytes) -> Result> { + #[derive(serde::Deserialize)] + struct Incoming { + from_pubkey: Option, + message: Option, + } + 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) { + tracing::info!("📩 Received message from {}: {}", from, msg); + node_msg::store_received(&from, &msg).await; + } + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", CORS_ANY) + .body(hyper::Body::from(r#"{"ok":true}"#)) + .unwrap()) + } + + async fn handle_electrs_status() -> Result> { + 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") + .header("Access-Control-Allow-Origin", CORS_ANY) + .body(hyper::Body::from(body)) + .unwrap()) + } + async fn handle_lnd_proxy(path: &str) -> Result> { let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); let url = format!("http://127.0.0.1:8080{}", suffix); diff --git a/core/archipelago/src/api/rpc.rs b/core/archipelago/src/api/rpc.rs index fea888c4..daa38ba4 100644 --- a/core/archipelago/src/api/rpc.rs +++ b/core/archipelago/src/api/rpc.rs @@ -1,10 +1,17 @@ use crate::auth::AuthManager; use crate::config::Config; +use crate::container::docker_packages; use crate::container::DevContainerOrchestrator; +use crate::identity; +use crate::node_message; +use crate::nostr_discovery; +use crate::peers::{self, KnownPeer}; +use crate::port_allocator::PortAllocator; +use crate::state::StateManager; use anyhow::{Context, Result}; use hyper::{Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tracing::{debug, error}; #[derive(Debug, Deserialize)] @@ -33,10 +40,12 @@ pub struct RpcHandler { config: Config, auth_manager: AuthManager, orchestrator: Option>, + state_manager: Arc, + port_allocator: Arc>, } impl RpcHandler { - pub async fn new(config: Config) -> Result { + pub async fn new(config: Config, state_manager: Arc) -> Result { let auth_manager = AuthManager::new(config.data_dir.clone()); let orchestrator = if config.dev_mode { Some(Arc::new( @@ -45,11 +54,14 @@ impl RpcHandler { } else { None }; + let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?)); Ok(Self { config, auth_manager, orchestrator, + state_manager, + port_allocator, }) } @@ -73,6 +85,9 @@ impl RpcHandler { "server.echo" => self.handle_echo(rpc_req.params).await, "auth.login" => self.handle_auth_login(rpc_req.params).await, "auth.logout" => self.handle_auth_logout().await, + "auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await, + "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, + "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(rpc_req.params).await, @@ -89,11 +104,26 @@ impl RpcHandler { "package.start" => self.handle_package_start(rpc_req.params).await, "package.stop" => self.handle_package_stop(rpc_req.params).await, "package.restart" => self.handle_package_restart(rpc_req.params).await, + "package.uninstall" => self.handle_package_uninstall(rpc_req.params).await, // Bundled app management (for pre-loaded container images) "bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await, "bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await, + // Node identity and P2P peers + "node-add-peer" => self.handle_node_add_peer(rpc_req.params).await, + "node-list-peers" => self.handle_node_list_peers().await, + "node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await, + "node-send-message" => self.handle_node_send_message(rpc_req.params).await, + "node-check-peer" => self.handle_node_check_peer(rpc_req.params).await, + "node-messages-received" => self.handle_node_messages_received().await, + "node-nostr-discover" => self.handle_node_nostr_discover().await, + "node.did" => self.handle_node_did().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-verify-revoked" => self.handle_node_nostr_verify_revoked().await, + _ => { Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method)) } @@ -174,6 +204,103 @@ impl RpcHandler { Ok(serde_json::Value::Null) } + async fn handle_auth_change_password( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let current_password = params + .get("currentPassword") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?; + let new_password = params + .get("newPassword") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?; + let also_change_ssh = params + .get("alsoChangeSsh") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + self.auth_manager + .change_password(current_password, new_password, also_change_ssh) + .await?; + + Ok(serde_json::json!({ "success": true })) + } + + async fn handle_auth_onboarding_complete(&self) -> Result { + self.auth_manager.complete_onboarding().await?; + Ok(serde_json::json!(true)) + } + + async fn handle_auth_is_onboarding_complete(&self) -> Result { + let complete = self.auth_manager.is_onboarding_complete().await?; + Ok(serde_json::json!(complete)) + } + + async fn handle_node_did(&self) -> Result { + let (data, _) = self.state_manager.get_snapshot().await; + let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey })) + } + + async fn handle_node_tor_address(&self) -> Result { + let tor_address = docker_packages::read_tor_address("archipelago"); + Ok(serde_json::json!({ "tor_address": tor_address })) + } + + async fn handle_node_nostr_publish(&self) -> Result { + if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() { + anyhow::bail!( + "Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable." + ); + } + let (data, _) = self.state_manager.get_snapshot().await; + let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let node_address = data + .server_info + .node_address + .as_deref() + .unwrap_or("archipelago://unknown"); + let identity_dir = self.config.data_dir.join("identity"); + let output = nostr_discovery::publish_node_identity( + &identity_dir, + &did, + node_address, + &data.server_info.version, + &self.config.nostr_relays, + self.config.nostr_tor_proxy.as_deref(), + ) + .await?; + Ok(serde_json::json!({ + "event_id": output.id().to_hex(), + "success": output.success.len(), + "failed": output.failed.len(), + })) + } + + async fn handle_node_nostr_pubkey(&self) -> Result { + let identity_dir = self.config.data_dir.join("identity"); + let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?; + Ok(serde_json::json!({ "nostr_pubkey": pubkey })) + } + + async fn handle_node_nostr_verify_revoked(&self) -> Result { + let identity_dir = self.config.data_dir.join("identity"); + let status = nostr_discovery::verify_revocation( + &identity_dir, + self.config.nostr_tor_proxy.as_deref(), + ) + .await?; + Ok(serde_json::json!({ + "revoked": status.revoked, + "nostr_pubkey": status.nostr_pubkey, + "latest_content": status.latest_content, + "error": status.error, + })) + } + async fn handle_container_install( &self, params: Option, @@ -525,17 +652,34 @@ impl RpcHandler { ]; // App-specific configuration (should come from manifest) - let (ports, volumes, env_vars, custom_command) = get_app_config(package_id); + let (ports, volumes, env_vars, custom_command, custom_args) = { + let mut allocator = self.port_allocator.lock().map_err(|e| { + anyhow::anyhow!("Port allocator lock poisoned: {}", e) + })?; + get_app_config(package_id, &self.config.host_ip, &mut allocator) + }; - // Special handling for Tailscale: requires host network and privileged mode + // Special handling: Tailscale needs host network; mempool stack needs archy-net let is_tailscale = package_id == "tailscale"; - + let needs_archy_net = matches!( + package_id, + "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web" + | "btcpay-server" | "btcpayserver" | "archy-btcpay-db" + ); + 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 { + // Ensure archy-net exists, then attach + let _ = tokio::process::Command::new("sudo") + .args(["podman", "network", "create", "archy-net"]) + .output() + .await; + run_args.push("--network=archy-net"); } // Create data directories if they don't exist @@ -591,9 +735,11 @@ impl RpcHandler { let mut cmd = tokio::process::Command::new("sudo"); cmd.args(&run_args); - // Add custom command if specified (e.g., for Tailscale web UI) + // Add custom command/args if specified (Tailscale: shell override; electrs: CLI 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 @@ -627,35 +773,22 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; - // Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin") - // But also check if container exists without the prefix - let container_name = if let Ok(output) = tokio::process::Command::new("sudo") - .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) - .output() - .await - { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.trim().is_empty() { - debug!("Found container without prefix: {}", package_id); - package_id.to_string() - } else { - debug!("Using archy- prefix: archy-{}", package_id); - format!("archy-{}", package_id) - } + let containers = get_containers_for_app(package_id).await?; + let to_start: Vec = if containers.is_empty() { + vec![format!("archy-{}", package_id)] } else { - format!("archy-{}", package_id) + // Start order for mempool: db first, then api, then web + let order = ["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"]; + let mut sorted = containers; + sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99)); + sorted }; - - // Use podman CLI to start the container - let output = tokio::process::Command::new("sudo") - .args(["podman", "start", &container_name]) - .output() - .await - .context("Failed to execute podman start")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to start container: {}", stderr)); + for name in to_start { + let _ = tokio::process::Command::new("sudo") + .args(["podman", "start", &name]) + .output() + .await; } Ok(serde_json::Value::Null) @@ -671,34 +804,22 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; - // Convert package ID to container name - let container_name = if let Ok(output) = tokio::process::Command::new("sudo") - .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) - .output() - .await - { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.trim().is_empty() { - debug!("Found container without prefix: {}", package_id); - package_id.to_string() - } else { - debug!("Using archy- prefix: archy-{}", package_id); - format!("archy-{}", package_id) - } - } else { - format!("archy-{}", package_id) - }; - - // Use podman CLI to stop the container - let output = tokio::process::Command::new("sudo") - .args(["podman", "stop", &container_name]) - .output() - .await - .context("Failed to execute podman stop")?; + let containers = get_containers_for_app(package_id).await?; + if containers.is_empty() { + // Fallback: try single container + let container_name = format!("archy-{}", package_id); + let _ = tokio::process::Command::new("sudo") + .args(["podman", "stop", &container_name]) + .output() + .await; + return Ok(serde_json::Value::Null); + } - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to stop container: {}", stderr)); + for name in containers { + let _ = tokio::process::Command::new("sudo") + .args(["podman", "stop", &name]) + .output() + .await; } Ok(serde_json::Value::Null) @@ -714,39 +835,74 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; - // Convert package ID to container name - let container_name = if let Ok(output) = tokio::process::Command::new("sudo") - .args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)]) - .output() - .await - { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.trim().is_empty() { - debug!("Found container without prefix: {}", package_id); - package_id.to_string() - } else { - debug!("Using archy- prefix: archy-{}", package_id); - format!("archy-{}", package_id) - } - } else { - format!("archy-{}", package_id) - }; - - // Use podman CLI to restart the container - let output = tokio::process::Command::new("sudo") - .args(["podman", "restart", &container_name]) - .output() - .await - .context("Failed to execute podman restart")?; + let containers = get_containers_for_app(package_id).await?; + if containers.is_empty() { + let container_name = format!("archy-{}", package_id); + let _ = tokio::process::Command::new("sudo") + .args(["podman", "restart", &container_name]) + .output() + .await; + return Ok(serde_json::Value::Null); + } - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Failed to restart container: {}", stderr)); + for name in containers { + let _ = tokio::process::Command::new("sudo") + .args(["podman", "restart", &name]) + .output() + .await; } Ok(serde_json::Value::Null) } + /// Uninstall a package: stop and remove all related containers, clean data. No fragments left. + async fn handle_package_uninstall( + &self, + params: Option, + ) -> Result { + 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"))?; + let preserve_data = params + .get("preserve_data") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Get all container names for this app (handles multi-container apps like mempool) + let containers_to_remove = get_containers_for_app(package_id).await?; + + for name in &containers_to_remove { + let _ = tokio::process::Command::new("sudo") + .args(["podman", "stop", name]) + .output() + .await; + let _ = tokio::process::Command::new("sudo") + .args(["podman", "rm", "-f", name]) + .output() + .await; + } + + // Release port allocation + if let Ok(mut allocator) = self.port_allocator.lock() { + let _ = allocator.release(package_id); + } + + // Clean data directories unless preserve_data + if !preserve_data { + let data_dirs = get_data_dirs_for_app(package_id); + for dir in &data_dirs { + let _ = tokio::process::Command::new("sudo") + .args(["rm", "-rf", dir]) + .output() + .await; + } + } + + Ok(serde_json::json!({ "status": "uninstalled" })) + } + /// Start a bundled app (create container from pre-loaded image if needed, then start) async fn handle_bundled_app_start( &self, @@ -858,6 +1014,150 @@ impl RpcHandler { Ok(serde_json::json!({ "status": "stopped", "app_id": app_id })) } + + async fn handle_node_add_peer( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; + let pubkey = params + .get("pubkey") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?; + let name = params.get("name").and_then(|v| v.as_str()).map(String::from); + + let peer = KnownPeer { + onion: onion.to_string(), + pubkey: pubkey.to_string(), + name, + added_at: Some(chrono::Utc::now().to_rfc3339()), + }; + let peers = peers::add_peer(&self.config.data_dir, peer).await?; + Ok(serde_json::json!({ "peers": peers })) + } + + async fn handle_node_list_peers(&self) -> Result { + let peers = peers::load_peers(&self.config.data_dir).await?; + Ok(serde_json::json!({ "peers": peers })) + } + + async fn handle_node_remove_peer( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let pubkey = params + .get("pubkey") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?; + let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?; + Ok(serde_json::json!({ "peers": peers })) + } + + async fn handle_node_send_message( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; + let message = params + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing message"))?; + let (data, _) = self.state_manager.get_snapshot().await; + let pubkey = data.server_info.pubkey.clone(); + node_message::send_to_peer(onion, &pubkey, message).await?; + Ok(serde_json::json!({ "ok": true, "sent_to": onion })) + } + + async fn handle_node_check_peer( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion"))?; + let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false); + Ok(serde_json::json!({ "onion": onion, "reachable": reachable })) + } + + async fn handle_node_messages_received(&self) -> Result { + let messages = node_message::get_received(); + Ok(serde_json::json!({ "messages": messages })) + } + + async fn handle_node_nostr_discover(&self) -> Result { + let identity_dir = self.config.data_dir.join("identity"); + let nodes = nostr_discovery::discover_archipelago_nodes( + &identity_dir, + &self.config.nostr_relays, + self.config.nostr_tor_proxy.as_deref(), + ) + .await?; + Ok(serde_json::json!({ "nodes": nodes })) + } +} + +/// Get all container names for an app (handles multi-container apps like mempool) +async fn get_containers_for_app(package_id: &str) -> Result> { + let output = tokio::process::Command::new("sudo") + .args(["podman", "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(); + + // Map app id to container name patterns (support both archy-* and bare names) + let patterns: Vec = match package_id { + "mempool" | "mempool-web" => { + vec![ + "mempool-electrs".into(), + "mempool-api".into(), + "archy-mempool-api".into(), + "archy-mempool-web".into(), + "mempool".into(), + "archy-mempool-db".into(), + "mysql-mempool".into(), + ] + } + "fedimint" => vec!["fedimint".into(), "fedimint-ui".into(), "archy-fedimint".into()], + _ => vec![package_id.to_string(), format!("archy-{}", package_id)], + }; + + let mut result = Vec::new(); + for name in all { + for pat in &patterns { + if name == pat { + result.push(name.to_string()); + break; + } + } + } + Ok(result) +} + +/// Get data directories to clean for an app +fn get_data_dirs_for_app(package_id: &str) -> Vec { + let base = "/var/lib/archipelago"; + match package_id { + "mempool" | "mempool-web" => vec![ + format!("{}/mempool", base), + format!("{}/mysql-mempool", base), + format!("{}/mempool-electrs", base), + ], + "fedimint" => vec![format!("{}/fedimint", base)], + _ => vec![format!("{}/{}", base, package_id)], + } } /// Validate Docker image name format @@ -886,106 +1186,204 @@ fn is_valid_docker_image(image: &str) -> bool { } /// Get app-specific configuration -/// Returns: (ports, volumes, env_vars, custom_command) +/// Returns: (ports, volumes, env_vars, custom_command, custom_args) +/// custom_command: shell override (e.g. "sh -c '...'"); custom_args: extra args for entrypoint +/// Uses port_allocator for apps with web UIs to avoid conflicts (e.g. Nextcloud vs LND UI). /// TODO: Load from manifest.yml files in apps/ directory -fn get_app_config(app_id: &str) -> (Vec, Vec, Vec, Option) { +fn get_app_config( + app_id: &str, + host_ip: &str, + allocator: &mut PortAllocator, +) -> (Vec, Vec, Vec, Option, Option>) { 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" => ( vec!["8332:8332".to_string(), "8333:8333".to_string()], vec!["/var/lib/archipelago/bitcoin:/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!["BITCOIN_ACTIVE=1".to_string()], None, + None, ), "btcpay-server" | "btcpayserver" => ( vec!["23000:49392".to_string()], vec!["/var/lib/archipelago/btcpay:/datadir".to_string()], - vec![], + 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), + "BTCPAY_BTCRPCUSER=archipelago".to_string(), + "BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(), + "BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(), + ], + None, None, ), - "mempool" => ( - vec!["8999:8080".to_string()], + "mempool" | "mempool-web" => ( + vec!["4080:8080".to_string()], vec![], + // Frontend proxies to backend at host:8999 (deploy script uses mempool-api when on archy-net) + 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=mempool-electrs".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(), + "CORE_RPC_USERNAME=bitcoin".to_string(), + "CORE_RPC_PASSWORD=bitcoinpass".to_string(), + "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, + ), + "mempool-electrs" => ( + vec!["50001:50001".to_string()], + vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()], vec![], None, + Some(vec![ + "--daemon-rpc-addr".to_string(), + format!("{}:8332", host_ip), + "--cookie".to_string(), + "bitcoin:bitcoinpass".to_string(), + "--jsonrpc-import".to_string(), + "--electrum-rpc-addr".to_string(), + "0.0.0.0:50001".to_string(), + "--db-dir".to_string(), + "/data".to_string(), + "--lightmode".to_string(), + ]), + ), + "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![], None, + None, ), "searxng" => ( vec!["8888:8080".to_string()], vec![], 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, - ), - "nextcloud" => ( - vec!["8081:80".to_string()], - vec!["/var/lib/archipelago/nextcloud:/var/www/html".to_string()], - vec![], - None, - ), - "vaultwarden" => ( - vec!["8082:80".to_string()], - vec!["/var/lib/archipelago/vaultwarden:/data".to_string()], - vec![], None, ), + "nextcloud" => { + let host_port = allocator + .allocate_or_get(app_id, 8085, 80) + .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) + .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![], None, + None, ), "immich" => ( vec!["2283:3001".to_string()], vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()], vec![], None, - ), - "filebrowser" => ( - vec!["8083:80".to_string()], - vec!["/var/lib/archipelago/filebrowser:/srv".to_string()], - vec![], None, ), + "filebrowser" => { + let host_port = allocator + .allocate_or_get(app_id, 8083, 80) + .unwrap_or(8083); + ( + vec![format!("{}:80", host_port)], + vec!["/var/lib/archipelago/filebrowser:/srv".to_string()], + vec![], + None, + None, + ) + } "nginx-proxy-manager" => ( vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()], vec![ @@ -994,18 +1392,21 @@ fn get_app_config(app_id: &str) -> (Vec, Vec, Vec, Optio ], 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![], None, + None, ), "tailscale" => ( vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network) @@ -1016,18 +1417,30 @@ fn get_app_config(app_id: &str) -> (Vec, Vec, Vec, Optio "TS_STATE_DIR=/var/lib/tailscale".to_string(), ], Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string()), - ), - "fedimint" => ( - vec!["8173:8173".to_string()], - vec!["/var/lib/archipelago/fedimint:/data".to_string()], - vec![ - "FM_BITCOIN_RPC_KIND=bitcoind".to_string(), - "FM_BITCOIN_RPC_URL=http://host.containers.internal:8332".to_string(), - "FM_BIND_P2P=0.0.0.0:8173".to_string(), - "FM_BIND_API=0.0.0.0:8174".to_string(), - ], None, ), - _ => (vec![], vec![], vec![], None), // No default config, user must configure manually + "fedimint" => ( + vec![ + "8173:8173".to_string(), // P2P + "8174:8174".to_string(), // API (JSON-RPC) + "8175:8175".to_string(), // Built-in Guardian UI + ], + vec!["/var/lib/archipelago/fedimint:/data".to_string()], + vec![ + "FM_DATA_DIR=/data".to_string(), + "FM_BITCOIND_USERNAME=bitcoin".to_string(), + "FM_BITCOIND_PASSWORD=bitcoinpass".to_string(), + "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, + None, + ), + _ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually } } diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index b290190e..96bd2850 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio::fs; +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OnboardingState { + complete: bool, +} + #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -43,13 +48,16 @@ impl AuthManager { pub async fn setup_user(&self, password: &str) -> Result<()> { use bcrypt::{hash, DEFAULT_COST}; - + let password_hash = hash(password, DEFAULT_COST)?; - + + // If onboarding was already completed (before setup), preserve that + let onboarding_complete = self.is_onboarding_complete().await?; + let user = User { password_hash, setup_complete: true, - onboarding_complete: false, + onboarding_complete, }; let user_file = self.data_dir.join("user.json"); @@ -60,6 +68,15 @@ impl AuthManager { } pub async fn complete_onboarding(&self) -> Result<()> { + // Persist to onboarding.json (works even before user/setup exists) + let onboarding_file = self.data_dir.join("onboarding.json"); + let state = OnboardingState { complete: true }; + fs::write( + &onboarding_file, + serde_json::to_string_pretty(&state)?, + ) + .await?; + // Also update user.json if it exists (keeps them in sync) if let Some(mut user) = self.get_user().await? { user.onboarding_complete = true; let user_file = self.data_dir.join("user.json"); @@ -69,6 +86,25 @@ impl AuthManager { Ok(()) } + pub async fn is_onboarding_complete(&self) -> Result { + // Check onboarding.json first (persisted before user setup) + let onboarding_file = self.data_dir.join("onboarding.json"); + if onboarding_file.exists() { + let content = fs::read_to_string(&onboarding_file).await?; + if let Ok(state) = serde_json::from_str::(&content) { + if state.complete { + return Ok(true); + } + } + } + // Fallback: user.json + Ok(self + .get_user() + .await? + .map(|u| u.onboarding_complete) + .unwrap_or(false)) + } + pub async fn verify_password(&self, password: &str) -> Result { use bcrypt::verify; @@ -78,4 +114,113 @@ impl AuthManager { Ok(false) } } + + /// Change password: verify current, validate new, update user.json and optionally SSH. + /// New password must be 12+ chars with upper, lower, digit, and special character. + pub async fn change_password( + &self, + current_password: &str, + new_password: &str, + also_change_ssh: bool, + ) -> Result<()> { + use bcrypt::{hash, DEFAULT_COST}; + + if !self.verify_password(current_password).await? { + anyhow::bail!("Current password is incorrect"); + } + + validate_password_strength(new_password)?; + + let password_hash = hash(new_password, DEFAULT_COST)?; + + let mut user = self + .get_user() + .await? + .ok_or_else(|| anyhow::anyhow!("User not set up"))?; + + user.password_hash = password_hash; + let user_file = self.data_dir.join("user.json"); + let content = serde_json::to_string_pretty(&user)?; + fs::write(&user_file, content).await?; + + if also_change_ssh { + change_ssh_password(new_password).await?; + } + + Ok(()) + } +} + +/// Validate password strength: 12+ chars, upper, lower, digit, special. +fn validate_password_strength(password: &str) -> Result<()> { + if password.len() < 12 { + anyhow::bail!("Password must be at least 12 characters"); + } + if !password.chars().any(|c| c.is_ascii_uppercase()) { + anyhow::bail!("Password must contain at least one uppercase letter"); + } + if !password.chars().any(|c| c.is_ascii_lowercase()) { + anyhow::bail!("Password must contain at least one lowercase letter"); + } + if !password.chars().any(|c| c.is_ascii_digit()) { + anyhow::bail!("Password must contain at least one digit"); + } + if !password.chars().any(|c| !c.is_ascii_alphanumeric()) { + anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)"); + } + Ok(()) +} + +/// Change the archipelago user's SSH/login password. +/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors). +/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH. +async fn change_ssh_password(new_password: &str) -> Result<()> { + let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string()); + + // Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow) + // Use /usr/bin/openssl - systemd services often have minimal PATH + let mut hash_child = tokio::process::Command::new("/usr/bin/openssl") + .args(["passwd", "-6", "-stdin"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?; + + { + use tokio::io::AsyncWriteExt; + let mut stdin = hash_child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?; + stdin.write_all(new_password.as_bytes()).await?; + stdin.flush().await?; + } + + let hash_result = hash_child.wait_with_output().await?; + if !hash_result.status.success() { + let stderr = String::from_utf8_lossy(&hash_result.stderr); + anyhow::bail!("openssl passwd failed: {}", stderr); + } + let hash = String::from_utf8(hash_result.stdout)? + .trim() + .to_string(); + if hash.is_empty() { + anyhow::bail!("openssl passwd produced empty hash"); + } + + // usermod -p writes directly to /etc/shadow, bypassing PAM + // Use /usr/sbin/usermod - not always in systemd's PATH + let status = tokio::process::Command::new("/usr/sbin/usermod") + .args(["-p", &hash, &ssh_user]) + .output() + .await?; + + if !status.status.success() { + let stderr = String::from_utf8_lossy(&status.stderr); + anyhow::bail!("usermod failed: {}", stderr); + } + + tracing::info!("SSH password updated for user {}", ssh_user); + Ok(()) } diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index 91cb2ffb..4378fd08 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -45,15 +45,40 @@ pub struct Config { pub bind_host: String, pub bind_port: u16, pub log_level: String, + /// Host IP for container env vars (FM_API_URL, BACKEND_MAINNET_HTTP_HOST, etc.) + pub host_ip: String, // Dev mode configuration pub dev_mode: bool, pub container_runtime: ContainerRuntime, pub port_offset: u16, pub bitcoin_simulation: BitcoinSimulation, pub dev_data_dir: PathBuf, + /// Nostr discovery: opt-in only. When true + relays non-empty, publish node to relays. + #[serde(default)] + pub nostr_discovery_enabled: bool, + /// Nostr relay URLs (comma-separated). Only used when nostr_discovery_enabled. + #[serde(default)] + pub nostr_relays: Vec, + /// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor. + #[serde(default)] + pub nostr_tor_proxy: Option, } impl Config { + /// Detect primary host IP (first non-loopback IPv4) + fn detect_host_ip() -> Result { + let output = std::process::Command::new("hostname") + .args(["-I"]) + .output() + .context("Failed to run hostname -I")?; + let s = String::from_utf8_lossy(&output.stdout); + let ip = s + .split_whitespace() + .find(|s| !s.starts_with("127.") && s.contains('.')) + .unwrap_or("127.0.0.1"); + Ok(ip.to_string()) + } + pub async fn load() -> Result { // Default configuration let mut config = Self::default(); @@ -124,6 +149,29 @@ impl Config { config.dev_data_dir = PathBuf::from(dev_data_dir); } + // Nostr discovery (opt-in, secure by default) + if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED") { + config.nostr_discovery_enabled = v.parse().unwrap_or(false); + } + if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_RELAYS") { + config.nostr_relays = v + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY") { + let s = v.trim().to_string(); + config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) }; + } + + // Host IP for container env vars (detect if not set) + if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") { + config.host_ip = ip; + } else { + config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string()); + } + // Ensure data directory exists fs::create_dir_all(&config.data_dir).await .context("Failed to create data directory")?; @@ -145,11 +193,18 @@ impl Default for Config { bind_host: "0.0.0.0".to_string(), bind_port: 5678, log_level: "info".to_string(), + host_ip: "127.0.0.1".to_string(), dev_mode: false, container_runtime: ContainerRuntime::Auto, port_offset: 10000, bitcoin_simulation: BitcoinSimulation::Mock, dev_data_dir: PathBuf::from("/tmp/archipelago-dev"), + nostr_discovery_enabled: true, + nostr_relays: vec![ + "wss://relay.damus.io".into(), + "wss://relay.nostr.info".into(), + ], + nostr_tor_proxy: Some("127.0.0.1:9050".into()), } } } diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index ac244aa2..ac11d40b 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -53,11 +53,15 @@ impl DockerPackageScanner { let mut ui_containers: HashMap = HashMap::new(); for container in &containers { if container.name.ends_with("-ui") { - // Map bitcoin-ui -> bitcoin, lnd-ui -> lnd + // Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup) let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name); + let canonical_id = parent_app + .strip_prefix("archy-") + .unwrap_or(parent_app) + .to_string(); if !container.ports.is_empty() { if let Some(ui_address) = extract_lan_address(&container.ports) { - ui_containers.insert(parent_app.to_string(), ui_address); + ui_containers.insert(canonical_id, ui_address); } } } @@ -109,6 +113,14 @@ impl DockerPackageScanner { // But web UI is always on port 8240 debug!("Tailscale detected, using port 8240"); Some("http://localhost:8240".to_string()) + } else if app_id == "fedimint" { + // Fedimint built-in Guardian UI on port 8175 + debug!("Using fedimint built-in Guardian UI: http://localhost:8175"); + Some("http://localhost:8175".to_string()) + } else if app_id == "mempool-electrs" || app_id == "electrs" { + // Electrs UI runs on host at port 50002 + debug!("Using electrs-ui for mempool-electrs: http://localhost:50002"); + Some("http://localhost:50002".to_string()) } else { // Extract port from the main container extract_lan_address(&container.ports) @@ -119,6 +131,8 @@ impl DockerPackageScanner { // Convert container state to package/service state let (package_state, service_status) = convert_state(&container.state); + let tor_address = read_tor_address(&app_id); + let package = PackageDataEntry { state: package_state.clone(), static_files: StaticFiles { @@ -143,11 +157,11 @@ impl DockerPackageScanner { donation_url: None, author: Some("Archipelago".to_string()), website: lan_address.clone(), - interfaces: if lan_address.is_some() { + interfaces: if lan_address.is_some() || tor_address.is_some() { Some(Interfaces { main: Some(MainInterface { ui: Some("true".to_string()), - tor_config: None, + tor_config: tor_address.clone(), lan_config: None, }), }) @@ -159,13 +173,17 @@ impl DockerPackageScanner { current_dependents: HashMap::new(), current_dependencies: HashMap::new(), last_backup: None, - interface_addresses: if let Some(addr) = lan_address { + interface_addresses: if lan_address.is_some() || tor_address.is_some() { let mut addresses = HashMap::new(); + // Only include tor_address if we have a real v3 .onion (not placeholder) + let tor = tor_address + .filter(|s| is_real_onion_address(s)) + .unwrap_or_default(); addresses.insert( "main".to_string(), InterfaceAddress { - tor_address: format!("{}.onion", app_id), - lan_address: Some(addr), + tor_address: tor, + lan_address: lan_address, }, ); addresses @@ -227,7 +245,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { "fedimint" => AppMetadata { title: "Fedimint".to_string(), description: "Federated Bitcoin mint".to_string(), - icon: "/assets/img/icon-fedimint.jpeg".to_string(), + icon: "/assets/img/app-icons/fedimint.png".to_string(), repo: "https://github.com/fedimint/fedimint".to_string(), }, "morphos" | "morphos-server" => AppMetadata { @@ -248,6 +266,12 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { icon: "/assets/img/app-icons/mempool.webp".to_string(), repo: "https://github.com/mempool/mempool".to_string(), }, + "mempool-electrs" | "electrs" => AppMetadata { + title: "Electrs".to_string(), + description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(), + icon: "/assets/img/app-icons/electrs.svg".to_string(), + repo: "https://github.com/romanz/electrs".to_string(), + }, "ollama" => AppMetadata { title: "Ollama".to_string(), description: "Run large language models locally".to_string(), @@ -347,6 +371,40 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { } } +/// Map app_id to Tor hidden service directory name. +/// "archipelago" is the main web UI (nginx port 80). +/// Supports container names from deploy (archy-*, btcpay-server, etc.). +fn tor_service_name(app_id: &str) -> Option<&'static str> { + match app_id { + "archipelago" => Some("archipelago"), + "lnd" | "lnd-ui" => Some("lnd"), + "btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"), + "mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"), + "fedimint" => Some("fedimint"), + _ => None, + } +} + +/// V3 onion addresses are 56 base32 chars + ".onion". Placeholders like "btcpay.onion" are not real. +fn is_real_onion_address(s: &str) -> bool { + s.ends_with(".onion") && s.len() >= 60 && s.len() <= 70 +} + +/// Read real .onion address from Tor hidden service hostname file. +/// Service name "archipelago" is for the main web UI (nginx port 80). +/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor. +pub fn read_tor_address(app_id: &str) -> Option { + let service = tor_service_name(app_id)?; + let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string()); + let path = std::path::Path::new(&base) + .join(format!("hidden_service_{}", service)) + .join("hostname"); + std::fs::read_to_string(&path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| s.ends_with(".onion") && !s.is_empty()) +} + fn extract_lan_address(ports: &[String]) -> Option { for port_str in ports { // Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp" diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index 3869fd4e..f3e7cd93 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -22,6 +22,10 @@ pub struct ServerInfo { pub status_info: StatusInfo, #[serde(rename = "lan-address")] pub lan_address: Option, + #[serde(rename = "tor-address")] + pub tor_address: Option, + #[serde(rename = "node-address", skip_serializing_if = "Option::is_none")] + pub node_address: Option, pub unread: u32, #[serde(rename = "wifi-ssids")] pub wifi_ssids: Vec, @@ -225,6 +229,8 @@ impl DataModel { update_progress: None, }, lan_address: Some("http://localhost:8100".to_string()), + tor_address: None, + node_address: None, unread: 0, wifi_ssids: vec![], zram_enabled: false, diff --git a/core/archipelago/src/electrs_status.rs b/core/archipelago/src/electrs_status.rs new file mode 100644 index 00000000..fe540e0e --- /dev/null +++ b/core/archipelago/src/electrs_status.rs @@ -0,0 +1,158 @@ +//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core. + +use anyhow::{Context, Result}; +use serde::Serialize; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::time::Duration; + +const ELECTRS_HOST: &str = "127.0.0.1"; +const ELECTRS_PORT: u16 = 50001; +const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/"; +const BITCOIN_RPC_AUTH: &str = "Basic YXJjaGlwZWxhZ286YXJjaGlwZWxhZ28xMjM="; // archipelago:archipelago123 + +#[derive(Debug, Serialize)] +pub struct ElectrsSyncStatus { + pub indexed_height: u64, + pub network_height: u64, + pub progress_pct: f64, + pub status: String, + pub error: Option, +} + +/// Fetch electrs indexed height via Electrum protocol (TCP JSON-RPC). +fn electrs_indexed_height() -> Result { + let mut stream = TcpStream::connect((ELECTRS_HOST, ELECTRS_PORT)) + .context("Failed to connect to electrs")?; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .context("set_read_timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .context("set_write_timeout")?; + + // blockchain.numblocks.subscribe returns current block height directly + let req = r#"{"id":1,"method":"blockchain.numblocks.subscribe","params":[]} +"#; + stream.write_all(req.as_bytes())?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + let line = line.trim(); + if line.is_empty() { + anyhow::bail!("Empty response from electrs"); + } + + let json: serde_json::Value = serde_json::from_str(line)?; + // blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N} + let height = json + .get("result") + .and_then(|r| r.as_u64()) + .or_else(|| { + json.get("result") + .and_then(|r| r.get("block_height")) + .and_then(|h| h.as_u64()) + }) + .context("Missing height in electrs response")?; + Ok(height) +} + +/// Fetch Bitcoin network height via JSON-RPC. +async fn bitcoin_network_height() -> Result { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "electrs-status", + "method": "getblockcount", + "params": [] + }); + let resp = client + .post(BITCOIN_RPC_URL) + .header("Content-Type", "application/json") + .header("Authorization", BITCOIN_RPC_AUTH) + .body(body.to_string()) + .send() + .await + .context("Bitcoin RPC request failed")?; + + if !resp.status().is_success() { + anyhow::bail!("Bitcoin RPC returned {}", resp.status()); + } + + let json: serde_json::Value = resp.json().await?; + let height = json + .get("result") + .and_then(|r| r.as_u64()) + .context("Missing result in Bitcoin RPC")?; + Ok(height) +} + +/// Get electrs sync status. Runs blocking electrs call in spawn_blocking. +pub async fn get_electrs_sync_status() -> ElectrsSyncStatus { + let network_height = match bitcoin_network_height().await { + Ok(h) => h, + Err(e) => { + return ElectrsSyncStatus { + indexed_height: 0, + network_height: 0, + progress_pct: 0.0, + status: "error".to_string(), + error: Some(format!("Bitcoin RPC: {}", e)), + }; + } + }; + + let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + // Electrs doesn't listen on 50001 until indexing completes (can take hours) + let err_msg = e.to_string(); + let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") { + ( + "indexing".to_string(), + Some("Electrs is building the index. Electrum RPC will be available when indexing completes (may take hours).".to_string()), + ) + } else { + ("error".to_string(), Some(format!("Electrs: {}", e))) + }; + return ElectrsSyncStatus { + indexed_height: 0, + network_height, + progress_pct: 0.0, + status, + error, + }; + } + Err(e) => { + return ElectrsSyncStatus { + indexed_height: 0, + network_height, + progress_pct: 0.0, + status: "error".to_string(), + error: Some(format!("Task: {}", e)), + }; + } + }; + + let progress_pct = if network_height > 0 { + (indexed_height as f64 / network_height as f64) * 100.0 + } else { + 0.0 + }; + + let status = if indexed_height >= network_height.saturating_sub(1) { + "synced" + } else { + "syncing" + }; + + ElectrsSyncStatus { + indexed_height, + network_height, + progress_pct, + status: status.to_string(), + error: None, + } +} diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs new file mode 100644 index 00000000..e3fbc884 --- /dev/null +++ b/core/archipelago/src/identity.rs @@ -0,0 +1,122 @@ +//! Node identity: persistent Ed25519 key for private identification. +//! Enables future P2P features (file transfer, streaming, ecash/Lightning). +//! Supports did:key (W3C) for Web5/DID interoperability. + +use anyhow::{Context, Result}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const NODE_KEY_FILE: &str = "node_key"; +const NODE_KEY_PUB_FILE: &str = "node_key.pub"; + +/// Persistent node identity (Ed25519 keypair). +/// Survives reboots; used for signing, verification, and node address. +pub struct NodeIdentity { + signing_key: SigningKey, + identity_dir: PathBuf, +} + +impl NodeIdentity { + /// Load existing identity or create and persist a new one. + pub async fn load_or_create(identity_dir: &Path) -> Result { + fs::create_dir_all(identity_dir) + .await + .context("Failed to create identity directory")?; + + let key_path = identity_dir.join(NODE_KEY_FILE); + let pub_path = identity_dir.join(NODE_KEY_PUB_FILE); + + let signing_key = if key_path.exists() { + let bytes = fs::read(&key_path) + .await + .context("Failed to read node key")?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid node key length"))?; + SigningKey::from_bytes(&arr) + } else { + let signing_key = SigningKey::generate(&mut OsRng); + fs::write(&key_path, signing_key.to_bytes()) + .await + .context("Failed to write 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, signing_key.verifying_key().as_bytes()) + .await + .context("Failed to write node public key")?; + tracing::info!("🔑 Generated new node identity at {}", identity_dir.display()); + signing_key + }; + + Ok(Self { + signing_key, + identity_dir: identity_dir.to_path_buf(), + }) + } + + /// Public key as hex string (for ServerInfo, Nostr, etc.) + pub fn pubkey_hex(&self) -> String { + hex::encode(self.signing_key.verifying_key().as_bytes()) + } + + /// Stable node ID derived from pubkey (first 16 chars of hex). + pub fn node_id(&self) -> String { + self.pubkey_hex().chars().take(16).collect() + } + + /// Sign data; returns hex-encoded signature. + pub fn sign(&self, data: &[u8]) -> String { + hex::encode(self.signing_key.sign(data).to_bytes()) + } + + /// Verify a signature from a peer (pubkey hex, data, signature hex). + pub fn verify(pubkey_hex: &str, data: &[u8], sig_hex: &str) -> Result { + let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?; + let verifying_key = VerifyingKey::from_bytes( + bytes + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?, + )?; + let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?; + let sig = Signature::from_bytes( + sig_bytes + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid signature length"))?, + ); + Ok(verifying_key.verify(data, &sig).is_ok()) + } + + /// Node address format for invites: archipelago://# + pub fn node_address(&self, onion: &str) -> String { + format!("archipelago://{}#{}", onion.trim_end_matches('/'), self.pubkey_hex()) + } + + /// DID in did:key format (W3C did:key method, Ed25519). + /// Format: did:key:z<base58btc(multicodec_ed25519_pub + 32-byte pubkey)> + pub fn did_key(&self) -> String { + did_key_from_pubkey_hex(&self.pubkey_hex()).expect("pubkey_hex is valid") + } +} + +/// Convert Ed25519 pubkey (hex) to did:key format. +/// Used by RPC when identity is loaded from state. +pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result { + let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?; + if bytes.len() != 32 { + return Err(anyhow::anyhow!("Invalid pubkey length")); + } + let mut multicodec_pubkey = [0u8; 34]; + multicodec_pubkey[0] = 0xed; + multicodec_pubkey[1] = 0x01; + multicodec_pubkey[2..34].copy_from_slice(&bytes); + Ok(format!("did:key:z{}", bs58::encode(multicodec_pubkey).into_string())) +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 451a19df..df2420a6 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -8,8 +8,14 @@ use tracing::info; mod api; mod auth; mod config; +mod electrs_status; mod container; +mod port_allocator; mod data_model; +mod identity; +mod node_message; +mod nostr_discovery; +mod peers; mod server; mod state; diff --git a/core/archipelago/src/node_message.rs b/core/archipelago/src/node_message.rs new file mode 100644 index 00000000..f61eadd1 --- /dev/null +++ b/core/archipelago/src/node_message.rs @@ -0,0 +1,133 @@ +//! Node-to-node messaging over Tor. +//! Sends messages to peer .onion addresses via SOCKS5 proxy. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::{Mutex, OnceLock}; + +const TOR_SOCKS: &str = "socks5h://127.0.0.1:9050"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomingMessage { + pub from_pubkey: String, + pub from_onion: Option, + pub message: String, + pub timestamp: String, +} + +fn received_messages() -> &'static Mutex> { + static RECEIVED: OnceLock>> = OnceLock::new(); + RECEIVED.get_or_init(|| Mutex::new(Vec::new())) +} + +const MAX_STORED: usize = 100; + +/// Store a received message (called from HTTP handler). +pub fn store_received_sync(from_pubkey: &str, message: &str) { + let mut guard = received_messages().lock().unwrap_or_else(|e| e.into_inner()); + guard.push(IncomingMessage { + from_pubkey: from_pubkey.to_string(), + from_onion: None, + message: message.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }); + let len = guard.len(); + if len > MAX_STORED { + guard.drain(0..len - MAX_STORED); + } +} + +pub async fn store_received(from_pubkey: &str, message: &str) { + store_received_sync(from_pubkey, message); +} + +/// Get received messages for UI display. +pub fn get_received() -> Vec { + received_messages().lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// Tor v3 onion hostname is 56 base32 chars (a-z, 2-7). Reject invalid formats. +fn validate_onion(onion: &str) -> Result<()> { + let host = onion.trim_end_matches(".onion"); + if host.len() != 56 { + anyhow::bail!( + "Invalid onion address (expected 56 chars, got {}). The peer may have wrong data - try removing and re-adding via Discover.", + host.len() + ); + } + let valid = host.chars().all(|c| c.is_ascii_lowercase() || (c >= '2' && c <= '7')); + if !valid { + anyhow::bail!("Invalid onion address: must be 56 base32 chars (a-z, 2-7)"); + } + Ok(()) +} + +/// Send a message to a peer over Tor. +pub async fn send_to_peer(onion: &str, from_pubkey: &str, message: &str) -> Result<()> { + validate_onion(onion)?; + + let host = if onion.ends_with(".onion") { + onion.to_string() + } else { + format!("{}.onion", onion) + }; + let url = format!("http://{}/archipelago/node-message", host); + let body = serde_json::json!({ + "from_pubkey": from_pubkey, + "message": message, + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + + let proxy = reqwest::Proxy::all(TOR_SOCKS).context("Invalid Tor proxy")?; + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(60)) + .build() + .context("Failed to build HTTP client")?; + + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("connection refused") || msg.contains("Connection refused") { + anyhow::anyhow!("Tor not reachable at 127.0.0.1:9050. Is the Tor container running?") + } else if msg.contains("timeout") || msg.contains("timed out") { + anyhow::anyhow!("Connection timed out. The peer may be offline or unreachable over Tor.") + } else { + anyhow::anyhow!("Failed to send over Tor: {}", msg) + } + })?; + + if !resp.status().is_success() { + anyhow::bail!( + "Peer returned {} {}. The peer may need /archipelago/ in its nginx config.", + resp.status().as_u16(), + resp.status().canonical_reason().unwrap_or("") + ); + } + Ok(()) +} + +/// Check if a peer is reachable (ping over Tor). +pub async fn check_peer_reachable(onion: &str) -> Result { + let host = if onion.ends_with(".onion") { + onion.to_string() + } else { + format!("{}.onion", onion) + }; + let url = format!("http://{}/health", host); + let proxy = reqwest::Proxy::all(TOR_SOCKS).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")?; + + match client.get(&url).send().await { + Ok(resp) => Ok(resp.status().is_success()), + Err(_) => Ok(false), + } +} diff --git a/core/archipelago/src/nostr_discovery.rs b/core/archipelago/src/nostr_discovery.rs new file mode 100644 index 00000000..8ba68fa0 --- /dev/null +++ b/core/archipelago/src/nostr_discovery.rs @@ -0,0 +1,345 @@ +//! Nostr node discovery: publish node identity to relays for peer discovery. +//! Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node". +//! +//! Security: Publishing is opt-in (ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED + relays). +//! All Nostr traffic routes through Tor when ARCHIPELAGO_NOSTR_TOR_PROXY is set. +//! Legacy revocation overwrites any previously published data on old public relays. + +use anyhow::{Context, Result}; +use nostr_sdk::prelude::*; +use nostr_sdk::pool; +use std::net::SocketAddr; +use std::path::Path; +use tokio::fs; + +/// Parse "host:port" to SocketAddr. Returns None if invalid. +fn parse_proxy_addr(s: &str) -> Option { + s.trim().parse().ok() +} + +const NOSTR_SECRET_FILE: &str = "nostr_secret"; +const NOSTR_PUB_FILE: &str = "nostr_pub"; +const NOSTR_REVOKED_FILE: &str = "nostr_revoked"; +const ARCHIPELAGO_KIND: u64 = 30078; +const D_TAG: &str = "archipelago-node"; + +/// Relays we previously published to (for one-time revocation overwrite only) +const LEGACY_RELAYS: &[&str] = &[ + "wss://relay.damus.io", + "wss://relay.nostr.info", +]; + +/// Load or create Nostr keys (secp256k1) for node discovery. +async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result { + let secret_path = identity_dir.join(NOSTR_SECRET_FILE); + let pub_path = identity_dir.join(NOSTR_PUB_FILE); + + let keys = if secret_path.exists() { + let hex_secret = fs::read_to_string(&secret_path) + .await + .context("Failed to read Nostr secret")?; + Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")? + } else { + let keys = Keys::generate(); + fs::create_dir_all(identity_dir) + .await + .context("Failed to create identity dir")?; + let hex = keys.secret_key().to_secret_hex(); + fs::write(&secret_path, hex) + .await + .context("Failed to write Nostr secret")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::task::spawn_blocking(move || { + std::fs::set_permissions(secret_path, std::fs::Permissions::from_mode(0o600)) + }) + .await + .context("spawn_blocking")? + .context("Failed to set Nostr key permissions")?; + } + fs::write(&pub_path, keys.public_key().to_hex()) + .await + .context("Failed to write Nostr pubkey")?; + tracing::info!("🔑 Generated Nostr discovery key"); + keys + }; + + Ok(keys) +} + +/// Load Nostr keys only if they exist (does not create). Used for revocation. +async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result> { + let secret_path = identity_dir.join(NOSTR_SECRET_FILE); + if !secret_path.exists() { + return Ok(None); + } + let hex_secret = fs::read_to_string(&secret_path) + .await + .context("Failed to read Nostr secret")?; + let keys = Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?; + Ok(Some(keys)) +} + +/// Publish a replaceable event with empty content to overwrite/revoke previously published data. +/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only. +/// Requires tor_proxy to avoid leaking IP to relay operators. +fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result { + let client = if let Some(proxy_str) = tor_proxy { + let addr = parse_proxy_addr(proxy_str) + .ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?; + let connection = Connection::new() + .proxy(addr) + .target(ConnectionTarget::All); + let opts = ClientOptions::new().connection(connection); + Client::builder().signer(keys).opts(opts).build() + } else { + Client::new(keys) + }; + Ok(client) +} + +/// Publish a replaceable event with empty content to overwrite/revoke previously published data. +/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only. +/// Only call when tor_proxy is set (avoids IP leak). +pub async fn publish_node_revocation( + identity_dir: &Path, + tor_proxy: Option<&str>, +) -> Result<()> { + let Some(keys) = load_nostr_keys_if_exists(identity_dir).await? else { + return Ok(()); // No keys = never published, nothing to revoke + }; + + let client = build_nostr_client(keys, tor_proxy)?; + for url in LEGACY_RELAYS { + let _ = client.add_relay(*url).await; + } + client.connect().await; + + // NIP-33 replaceable: empty content overwrites previous event + let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), "{}") + .tag(Tag::identifier(D_TAG)); + let _ = client.send_event_builder(builder).await; + client.disconnect().await; + + Ok(()) +} + +/// If we have Nostr keys but haven't revoked yet, publish revocation to overwrite legacy data. +/// Uses tor_proxy if set; otherwise tries 127.0.0.1:9050 (archy-tor default). Creates nostr_revoked sentinel. +pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> Result<()> { + let revoked_path = identity_dir.join(NOSTR_REVOKED_FILE); + if revoked_path.exists() { + return Ok(()); + } + if load_nostr_keys_if_exists(identity_dir).await?.is_none() { + return Ok(()); + } + // Use configured proxy or Tor default (archy-tor exposes 127.0.0.1:9050) + let proxy = tor_proxy.or(Some("127.0.0.1:9050")); + + if let Err(e) = publish_node_revocation(identity_dir, proxy).await { + tracing::warn!("Nostr revocation (non-fatal): {}", e); + return Ok(()); + } + + fs::create_dir_all(identity_dir).await?; + fs::write(&revoked_path, "").await?; + tracing::info!("🔒 Nostr discovery data revoked (overwritten on legacy relays)"); + Ok(()) +} + +/// Publish node identity to Nostr relays for discovery. +/// Content: { did, node_address, version } +/// Only call when relays are non-empty (opt-in). +/// When tor_proxy is set, routes through Tor to prevent IP exposure. +/// Skips if nostr_revoked sentinel exists (revocation must not be overwritten). +pub async fn publish_node_identity( + identity_dir: &Path, + did: &str, + node_address: &str, + version: &str, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result> { + if relays.is_empty() { + anyhow::bail!("No relays configured for Nostr discovery"); + } + if identity_dir.join(NOSTR_REVOKED_FILE).exists() { + tracing::debug!("Nostr discovery: skipping publish (revoked)"); + return Err(anyhow::anyhow!("Nostr discovery revoked")); + } + + let keys = load_or_create_nostr_keys(identity_dir).await?; + let client = build_nostr_client(keys, tor_proxy)?; + + let content = serde_json::json!({ + "did": did, + "node_address": node_address, + "version": version, + }) + .to_string(); + + for url in relays { + let _ = client.add_relay(url).await; + } + client.connect().await; + + let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), content) + .tag(Tag::identifier(D_TAG)); + let output = client.send_event_builder(builder).await?; + + client.disconnect().await; + + Ok(output) +} + +/// Get Nostr public key for this node (hex). +pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result { + let keys = load_or_create_nostr_keys(identity_dir).await?; + Ok(keys.public_key().to_hex()) +} + +/// Verify that our node's Nostr discovery data was revoked on the legacy relays. +/// Queries relays for our pubkey's kind 30078 events; if latest has empty content, revocation succeeded. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct RevocationStatus { + pub revoked: bool, + pub nostr_pubkey: String, + pub latest_content: Option, + pub error: Option, +} + +pub async fn verify_revocation( + identity_dir: &Path, + tor_proxy: Option<&str>, +) -> Result { + let keys = match load_nostr_keys_if_exists(identity_dir).await? { + Some(k) => k, + None => { + return Ok(RevocationStatus { + revoked: true, + nostr_pubkey: String::new(), + latest_content: None, + error: Some("No Nostr keys - never published".to_string()), + }); + } + }; + let pubkey_hex = keys.public_key().to_hex(); + + let anon_keys = Keys::generate(); + let client = build_nostr_client(anon_keys, tor_proxy)?; + for url in LEGACY_RELAYS { + let _ = client.add_relay(*url).await; + } + client.connect().await; + + let filter = Filter::new() + .kind(Kind::Custom(ARCHIPELAGO_KIND as u16)) + .identifier(D_TAG) + .author(keys.public_key()) + .limit(10); + let events = client + .fetch_events(filter, std::time::Duration::from_secs(15)) + .await + .map(|e| e.to_vec()) + .unwrap_or_default(); + client.disconnect().await; + + // NIP-33: latest event wins. fetch_events returns sorted by timestamp desc. + let mut events: Vec<_> = events.into_iter().collect(); + events.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + let latest = events.into_iter().next(); + let (revoked, latest_content) = match latest { + None => (true, None), + Some(ev) => { + let content = ev.content; + let is_revoked = content == "{}" || content.is_empty() || !content.contains("node_address"); + (is_revoked, Some(content)) + } + }; + + Ok(RevocationStatus { + revoked, + nostr_pubkey: pubkey_hex, + latest_content, + error: None, + }) +} + +/// Discovered Archipelago node from Nostr. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct DiscoveredNode { + pub did: String, + pub node_address: String, + pub onion: String, + pub pubkey: String, + pub version: String, +} + +/// Query Nostr relays for other Archipelago nodes. +/// Returns empty if relays is empty (opt-in discovery). +/// When tor_proxy is set, routes through Tor to prevent IP exposure. +pub async fn discover_archipelago_nodes( + identity_dir: &Path, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result> { + if relays.is_empty() { + return Ok(Vec::new()); + } + + let _keys = load_or_create_nostr_keys(identity_dir).await?; + let anon_keys = Keys::generate(); + let client = build_nostr_client(anon_keys, tor_proxy)?; + for url in relays { + let _ = client.add_relay(url).await; + } + client.connect().await; + + let filter = Filter::new() + .kind(Kind::Custom(ARCHIPELAGO_KIND as u16)) + .identifier(D_TAG) + .limit(50); + let events = client + .fetch_events(filter, std::time::Duration::from_secs(15)) + .await + .map(|e| e.to_vec()) + .unwrap_or_default(); + client.disconnect().await; + + let mut nodes = Vec::new(); + for event in events { + if let Ok(content) = serde_json::from_str::(&event.content) { + // Skip revoked/empty events + let node_address = content.get("node_address").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if node_address.is_empty() { + continue; + } + let did = content.get("did").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let version = content.get("version").and_then(|v| v.as_str()).unwrap_or("0.1").to_string(); + // Parse archipelago://xxx.onion#pubkey + let (onion, pubkey) = if node_address.starts_with("archipelago://") { + let rest = node_address.trim_start_matches("archipelago://"); + if let Some((o, p)) = rest.split_once('#') { + (o.to_string(), p.to_string()) + } else { + (rest.to_string(), "".to_string()) + } + } else { + ("".to_string(), "".to_string()) + }; + if !onion.is_empty() { + nodes.push(DiscoveredNode { + did, + node_address, + onion: onion.trim_end_matches('/').to_string(), + pubkey, + version, + }); + } + } + } + Ok(nodes) +} diff --git a/core/archipelago/src/peers.rs b/core/archipelago/src/peers.rs new file mode 100644 index 00000000..577c1385 --- /dev/null +++ b/core/archipelago/src/peers.rs @@ -0,0 +1,63 @@ +//! Known peer nodes for P2P discovery and connection. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnownPeer { + pub onion: String, + pub pubkey: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub added_at: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PeersFile { + pub peers: Vec, +} + +const PEERS_FILE: &str = "peers.json"; + +pub async fn load_peers(data_dir: &Path) -> Result> { + let path = data_dir.join(PEERS_FILE); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path) + .await + .context("Failed to read peers file")?; + let file: PeersFile = serde_json::from_str(&content).unwrap_or_default(); + Ok(file.peers) +} + +pub async fn save_peers(data_dir: &Path, peers: &[KnownPeer]) -> Result<()> { + let path = data_dir.join(PEERS_FILE); + fs::create_dir_all(data_dir).await.context("Failed to create data dir")?; + let file = PeersFile { + peers: peers.to_vec(), + }; + let content = serde_json::to_string_pretty(&file).context("Failed to serialize peers")?; + fs::write(&path, content).await.context("Failed to write peers file")?; + Ok(()) +} + +pub async fn add_peer(data_dir: &Path, peer: KnownPeer) -> Result> { + let mut peers = load_peers(data_dir).await?; + let exists = peers.iter().any(|p| p.pubkey == peer.pubkey); + if !exists { + peers.push(peer); + save_peers(data_dir, &peers).await?; + } + Ok(peers) +} + +pub async fn remove_peer(data_dir: &Path, pubkey: &str) -> Result> { + let mut peers = load_peers(data_dir).await?; + peers.retain(|p| p.pubkey != pubkey); + save_peers(data_dir, &peers).await?; + Ok(peers) +} diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs new file mode 100644 index 00000000..4e40958b --- /dev/null +++ b/core/archipelago/src/port_allocator.rs @@ -0,0 +1,148 @@ +//! Smart port allocation to prevent conflicts between apps. +//! +//! Tracks which host ports are in use and allocates free ports for new apps. +//! Persists allocations to disk so they survive restarts. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Ports reserved by system/deploy services (LND UI, Mempool, etc.). +/// These are never allocated to user-installed apps. +const RESERVED_PORTS: &[u16] = &[ + 80, 443, 81, // HTTP/HTTPS + 8332, 8333, 8334, // Bitcoin RPC/P2P + 9735, 10009, 8080, // LND P2P, gRPC, REST + 8081, // LND UI (archy-lnd-ui) + 4080, 8999, 50001, // Mempool stack + 23000, // BTCPay + 8173, 8174, 8175, // Fedimint + 8123, // Home Assistant + 3000, // Grafana + 11434, // Ollama + 9980, 9001, // OnlyOffice, Penpot + 8240, // Tailscale + 9000, // Portainer + 3001, // Uptime Kuma + 8888, // SearXNG + 8096, 2342, 2283, // Jellyfin, Photoprism, Immich + 8443, 8084, // NPM +]; + +/// Start of range for allocating web app ports when preferred is taken. +const WEB_PORT_RANGE_START: u16 = 8085; +const WEB_PORT_RANGE_END: u16 = 9999; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PortAllocations { + /// app_id -> (host_port, container_port) + allocations: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PortMapping { + host_port: u16, + container_port: u16, +} + +pub struct PortAllocator { + data_dir: std::path::PathBuf, + allocations: PortAllocations, +} + +impl PortAllocator { + pub fn new(data_dir: impl AsRef) -> Result { + let data_dir = data_dir.as_ref().to_path_buf(); + let path = data_dir.join("port_allocations.json"); + let allocations = if path.exists() { + let s = std::fs::read_to_string(&path) + .context("Failed to read port allocations")?; + serde_json::from_str(&s).unwrap_or_default() + } else { + PortAllocations::default() + }; + Ok(Self { + data_dir, + allocations, + }) + } + + fn save(&self) -> Result<()> { + let path = self.data_dir.join("port_allocations.json"); + std::fs::create_dir_all(&self.data_dir) + .context("Failed to create data dir for port allocations")?; + let s = serde_json::to_string_pretty(&self.allocations) + .context("Failed to serialize port allocations")?; + std::fs::write(&path, s).context("Failed to write port allocations")?; + Ok(()) + } + + fn is_reserved(&self, port: u16) -> bool { + RESERVED_PORTS.contains(&port) + } + + fn is_allocated(&self, port: u16) -> bool { + self.allocations + .allocations + .values() + .any(|m| m.host_port == port) + } + + fn is_available(&self, port: u16) -> bool { + !self.is_reserved(port) && !self.is_allocated(port) + } + + /// Allocate a host port for an app. Uses preferred_port if available, else finds next free. + pub fn allocate( + &mut self, + app_id: &str, + preferred_host_port: u16, + container_port: u16, + ) -> Result { + let host_port = if self.is_available(preferred_host_port) { + preferred_host_port + } else { + (WEB_PORT_RANGE_START..=WEB_PORT_RANGE_END) + .find(|&p| self.is_available(p)) + .ok_or_else(|| anyhow::anyhow!("No free port in range {}-{}", WEB_PORT_RANGE_START, WEB_PORT_RANGE_END))? + }; + + self.allocations.allocations.insert( + app_id.to_string(), + PortMapping { + host_port, + container_port, + }, + ); + self.save()?; + Ok(host_port) + } + + /// Get existing allocation for an app, if any. + pub fn get(&self, app_id: &str) -> Option<(u16, u16)> { + self.allocations.allocations.get(app_id).map(|m| { + (m.host_port, m.container_port) + }) + } + + /// Allocate or return existing. Use when installing/starting an app. + pub fn allocate_or_get( + &mut self, + app_id: &str, + preferred_host_port: u16, + container_port: u16, + ) -> Result { + if let Some((host, _)) = self.get(app_id) { + return Ok(host); + } + self.allocate(app_id, preferred_host_port, container_port) + } + + /// Release port when app is uninstalled. + pub fn release(&mut self, app_id: &str) -> Result<()> { + self.allocations.allocations.remove(app_id); + self.save()?; + Ok(()) + } +} diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index e0cd9601..908e0e23 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1,6 +1,8 @@ use crate::api::ApiHandler; use crate::config::{Config, ContainerRuntime}; -use crate::container::DockerPackageScanner; +use crate::container::{docker_packages, DockerPackageScanner}; +use crate::identity::{self, NodeIdentity}; +use crate::nostr_discovery; use crate::state::StateManager; use anyhow::Result; use hyper::server::conn::Http; @@ -13,6 +15,7 @@ use tracing::{debug, error, info}; pub struct Server { _config: Config, + _identity: Arc, api_handler: Arc, _state_manager: Arc, } @@ -20,17 +23,83 @@ pub struct Server { impl Server { pub async fn new(config: Config) -> Result { let state_manager = Arc::new(StateManager::new()); + + // Load node identity and set stable server_info + let identity_dir = config.data_dir.join("identity"); + let identity = NodeIdentity::load_or_create(&identity_dir).await?; + let (mut data, _) = state_manager.get_snapshot().await; + data.server_info.id = identity.node_id(); + data.server_info.pubkey = identity.pubkey_hex(); + data.server_info.tor_address = docker_packages::read_tor_address("archipelago"); + if let Some(ref tor) = data.server_info.tor_address { + data.server_info.node_address = Some(identity.node_address(tor)); + } + state_manager.update_data(data.clone()).await; + + // Revoke any previously published Nostr data (runs before publish so revocation is not overwritten) + let identity_dir = config.data_dir.join("identity"); + let tor_proxy_revoke = config.nostr_tor_proxy.clone(); + if let Err(e) = nostr_discovery::revoke_if_needed(&identity_dir, tor_proxy_revoke.as_deref()).await { + tracing::debug!("Nostr revoke (non-fatal): {}", e); + } + + // Publish node identity to Nostr only when opt-in (nostr_discovery_enabled + relays) + if config.nostr_discovery_enabled + && !config.nostr_relays.is_empty() + && data.server_info.node_address.is_some() + { + let identity_dir = config.data_dir.join("identity"); + let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); + let node_addr = data.server_info.node_address.clone().unwrap_or_default(); + let version = data.server_info.version.clone(); + let relays = config.nostr_relays.clone(); + let tor_proxy = config.nostr_tor_proxy.clone(); + tokio::spawn(async move { + if let Err(e) = nostr_discovery::publish_node_identity( + &identity_dir, + &did, + &node_addr, + &version, + &relays, + tor_proxy.as_deref(), + ) + .await + { + tracing::debug!("Nostr publish (non-fatal): {}", e); + } + }); + } + info!("🔑 Node identity: {} (pubkey: {}...)", identity.node_id(), &identity.pubkey_hex()[..16.min(identity.pubkey_hex().len())]); + + let identity = Arc::new(identity); let api_handler = Arc::new(ApiHandler::new(config.clone(), state_manager.clone()).await?); + // Periodic Tor address refresh (runs regardless of dev_mode) + // Picks up hostname when Tor creates it after startup/rotation (30-60s delay) + { + let state = state_manager.clone(); + let identity_clone = identity.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + if let Err(e) = refresh_tor_address(&state, identity_clone.as_ref()).await { + debug!("Tor address refresh (non-fatal): {}", e); + } + } + }); + } + // Initialize Docker scanner if in dev mode if config.dev_mode { let scanner = create_docker_scanner(&config).await?; let state = state_manager.clone(); + let identity_clone = identity.clone(); // Initial scan tokio::spawn(async move { info!("🐳 Scanning Docker containers..."); - if let Err(e) = scan_and_update_packages(&scanner, &state).await { + if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await { error!("Failed to scan Docker containers: {}", e); } @@ -38,7 +107,7 @@ impl Server { let mut interval = tokio::time::interval(Duration::from_secs(10)); loop { interval.tick().await; - if let Err(e) = scan_and_update_packages(&scanner, &state).await { + if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await { error!("Failed to update Docker containers: {}", e); } } @@ -47,6 +116,7 @@ impl Server { Ok(Self { _config: config, + _identity: identity, api_handler, _state_manager: state_manager, }) @@ -108,25 +178,42 @@ async fn create_docker_scanner(config: &Config) -> Result Ok(DockerPackageScanner::new(runtime)) } +async fn refresh_tor_address(state: &StateManager, identity: &NodeIdentity) -> Result<()> { + let tor_addr = docker_packages::read_tor_address("archipelago"); + let (current_data, _) = state.get_snapshot().await; + if tor_addr != current_data.server_info.tor_address { + let mut data = current_data; + data.server_info.tor_address = tor_addr.clone(); + data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t)); + state.update_data(data).await; + if let Some(ref addr) = tor_addr { + info!("🔒 Tor address updated: {}", addr); + } + } + Ok(()) +} + async fn scan_and_update_packages( scanner: &DockerPackageScanner, state: &StateManager, + identity: &NodeIdentity, ) -> Result<()> { let packages = scanner.scan_containers().await?; - // Only update if we have packages AND they're different from current state - if !packages.is_empty() { - let (current_data, _) = state.get_snapshot().await; - - // Check if packages actually changed to avoid unnecessary broadcasts - let packages_changed = current_data.package_data != packages; - - if packages_changed { - let mut data = current_data; + let (current_data, _) = state.get_snapshot().await; + let packages_changed = !packages.is_empty() && current_data.package_data != packages; + let tor_addr = docker_packages::read_tor_address("archipelago"); + let tor_changed = tor_addr != current_data.server_info.tor_address; + + if packages_changed || tor_changed { + let mut data = current_data; + if !packages.is_empty() { data.package_data = packages; - state.update_data(data).await; - debug!("📦 Container state changed, broadcasting update"); } + data.server_info.tor_address = tor_addr.clone(); + data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t)); + state.update_data(data).await; + debug!("📦 State changed (packages={}, tor={}), broadcasting update", packages_changed, tor_changed); } Ok(()) diff --git a/docker-compose.yml b/docker-compose.yml index 8c524588..6f014b3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,18 +97,27 @@ services: networks: - archy-net - # Fedimint (using guardians setup) + # Fedimint (v0.10+ with built-in Guardian UI) fedimint: - image: fedimint/fedimintd:v0.3.0 + image: fedimint/fedimintd:v0.10.0 container_name: archy-fedimint platform: linux/amd64 # Emulate x86 on ARM Macs ports: - - "8173:8173" + - "8173:8173" # P2P + - "8174:8174" # API (JSON-RPC) + - "8175:8175" # Built-in Guardian UI volumes: - fedimint-data:/data environment: + FM_BITCOIND_URL: http://bitcoin:18443 + FM_BITCOIND_USERNAME: bitcoin + FM_BITCOIND_PASSWORD: bitcoinpass + FM_BITCOIN_NETWORK: regtest FM_BIND_P2P: 0.0.0.0:8173 FM_BIND_API: 0.0.0.0:8174 + FM_BIND_UI: 0.0.0.0:8175 + depends_on: + - bitcoin restart: unless-stopped networks: - archy-net diff --git a/docker/electrs-ui/Dockerfile b/docker/electrs-ui/Dockerfile new file mode 100644 index 00000000..164f2ae6 --- /dev/null +++ b/docker/electrs-ui/Dockerfile @@ -0,0 +1,10 @@ +FROM docker.io/library/nginx:alpine + +COPY index.html /usr/share/nginx/html/ +COPY nginx.conf /etc/nginx/conf.d/default.conf + +RUN mkdir -p /usr/share/nginx/html/assets/img + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/electrs-ui/index.html b/docker/electrs-ui/index.html new file mode 100644 index 00000000..54a1a829 --- /dev/null +++ b/docker/electrs-ui/index.html @@ -0,0 +1,145 @@ + + + + + + + Electrs - Archipelago + + + + +
+
+ +
+
+
+
+ + + +
+
+

Electrs

+

Bitcoin Electrum indexer for Mempool & Electrum clients

+
+
+
+
+

Status

+

Checking...

+
+
+
+
+ +
+
+
+ + + +
+
+

Index Sync

+

Checking sync status...

+
+
+ +
+
+ Block 0 + 0% +
+
+
+
+
+ +
+
+

Indexed Height

+

-

+
+
+

Network Height

+

-

+
+
+

Electrum RPC

+

localhost:50001

+
+
+

Progress

+

-

+
+
+
+
+ + + + diff --git a/docker/electrs-ui/nginx.conf b/docker/electrs-ui/nginx.conf new file mode 100644 index 00000000..b053e560 --- /dev/null +++ b/docker/electrs-ui/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 50002; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /electrs-status { + proxy_pass http://127.0.0.1:5678/electrs-status; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + add_header Access-Control-Allow-Origin *; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/lnd-ui/nginx.conf b/docker/lnd-ui/nginx.conf index 8498243c..3aa17e6b 100644 --- a/docker/lnd-ui/nginx.conf +++ b/docker/lnd-ui/nginx.conf @@ -1,5 +1,5 @@ server { - listen 8081; + listen 80; server_name _; root /usr/share/nginx/html; diff --git a/docs/SECURITY-NOSTR-DISCOVERY.md b/docs/SECURITY-NOSTR-DISCOVERY.md new file mode 100644 index 00000000..ec274658 --- /dev/null +++ b/docs/SECURITY-NOSTR-DISCOVERY.md @@ -0,0 +1,42 @@ +# Nostr Discovery – Security & Data Exposure + +## If Someone Saw the Published Data + +The Nostr discovery feature previously published node identity (DID, Tor onion address, version) to public relays. If someone saw that data, here’s what they could have and how to respond. + +### What Could Have Been Seen + +1. **Relay operators** (relay.damus.io, relay.nostr.info): + - Your server’s **IP address** when it connected to publish + - The **Tor onion address** you advertised + - **Timing** of when you published + +2. **Anyone querying Nostr** for archipelago nodes: + - Your **Tor onion address** (designed to be shareable) + - Your **DID** (public identifier) + - **Software version** + +### Mitigations + +| Exposure | Mitigation | +|----------|------------| +| **IP address** | Cannot be undone. If relay operators logged it, they still have it. Consider: moving to a new IP, using a VPN for future traffic, or treating the server as potentially identified. | +| **Tor onion** | The revocation overwrites the Nostr event so new clients won’t see it. If someone cached the onion, they can still reach the node. To invalidate it: **rotate the Tor hidden service** (new onion, old one stops working). | +| **DID** | Public by design; no mitigation needed. | +| **Version** | Update to a newer version; old version info becomes less useful over time. | + +### Rotating the Tor Hidden Service (New Onion) + +To invalidate an exposed onion address: + +1. Stop the Tor container. +2. Remove the hidden service directory: + `rm -rf /var/lib/archipelago/tor/hidden_service_archipelago` +3. Restart the Tor container so it creates a new onion. +4. Update any peers or links that used the old onion. + +### Current Protections (Post-Fix) + +- **Revocation**: On startup, the backend publishes a replacement Nostr event with empty content, so normal discovery no longer shows your node. +- **Tor proxy**: Nostr traffic uses Tor (127.0.0.1:9050) so relay operators no longer see your IP. +- **Opt-in defaults**: Discovery is on by default but only uses configured relays and routes through Tor. diff --git a/docs/WEB5_NOSTR_IDENTITY.md b/docs/WEB5_NOSTR_IDENTITY.md new file mode 100644 index 00000000..eb91ec7d --- /dev/null +++ b/docs/WEB5_NOSTR_IDENTITY.md @@ -0,0 +1,68 @@ +# Web5 & Nostr Node Identity + +## Overview + +Archipelago establishes node identity using **did:key** (W3C) from the persistent Ed25519 key. This enables Web5/DID interoperability and provides an extensible foundation for Nostr discovery. + +## DID/Web5 Integration + +### Current Implementation + +- **Node identity**: Persistent Ed25519 key in `/var/lib/archipelago/identity/` +- **DID format**: `did:key:z` +- **RPC**: `node.did` returns `{ did, pubkey }` for the node +- **Onboarding**: DID generation is wired to the backend during onboarding; the node's DID is established at first boot + +### TBD Web5 Protocols + +The node identity is compatible with TBD Web5: + +- **did:key** is supported by `@web5/dids` and `@tbd54566975/web5` +- **DWN integration**: Future apps (web5-dwn, did-wallet) can resolve our DID for data exchange +- **Node address**: `archipelago://#` format for peer discovery + +### Extensibility + +1. **DID Document**: Could add a DID document endpoint for full Web5 resolution +2. **DWN protocols**: Define custom protocols for node-to-node sync (e.g. peer list, backup) +3. **did:dht**: Migrate to did:dht for DHT-based resolution if needed + +## Nostr Integration + +### Recommended Approach + +**NIP-33 Replaceable Events** (kind 30078) for Archipelago node discovery: + +``` +{ + "kind": 30078, + "pubkey": "", + "content": JSON.stringify({ + "did": "did:key:z6Mk...", + "node_address": "archipelago://xxx.onion#pubkey", + "version": "0.1.0" + }), + "tags": [["d", "archipelago-node"]] +} +``` + +### Implementation Plan + +1. **Nostr keypair**: Generate and persist secp256k1 key in `/var/lib/archipelago/identity/nostr_key` (Nostr uses secp256k1, not Ed25519) +2. **Publish on startup**: After identity load, publish replaceable event to default relays (e.g. wss://relay.damus.io, wss://relay.nostr.info) +3. **Discovery**: Other nodes query relays for `{"kinds": [30078], "#d": ["archipelago-node"]}` to find peers +4. **RPC**: `node.nostr-publish` to manually re-publish; `node.nostr-pubkey` to get our Nostr pubkey for following + +### Why Separate Keys? + +- **Ed25519** (did:key): Web5, DWN, VC signing +- **secp256k1** (Nostr): Nostr protocol requirement; bridges to Nostr ecosystem + +The DID remains the canonical identity; Nostr pubkey is a discovery/signaling channel. + +## Onboarding Flow + +1. **Intro** → **Path** → **DID** (fetches `node.did` from backend) → **Backup** → **Verify** → **Login** +2. Onboarding completion is persisted to backend (`auth.onboardingComplete` → `onboarding.json`) +3. Returning users skip onboarding and go directly to login +4. State is server-side; no reliance on browser localStorage for completion status diff --git a/image-recipe/archipelago-scripts/archipelago-menu.sh b/image-recipe/archipelago-scripts/archipelago-menu.sh index c16a985b..28039133 100755 --- a/image-recipe/archipelago-scripts/archipelago-menu.sh +++ b/image-recipe/archipelago-scripts/archipelago-menu.sh @@ -118,6 +118,7 @@ main_menu() { echo " Main Menu:" echo " ─────────────────────────────────────────────────────────────" echo "" + echo " r) Refresh - Update IP/status (no restart needed)" echo " w) Open Web UI - Launch graphical interface" echo "" echo " 1) Install to Disk - Permanently install Archipelago" @@ -133,6 +134,9 @@ main_menu() { read -p " Select option: " choice case $choice in + r|R) + # Refresh - just loop again to show updated IP/status + ;; w|W) echo "" # Start the real backend on port 5678 diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 69598d12..acc9d197 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -68,6 +68,9 @@ check_tools() { if ! command -v xorriso >/dev/null 2>&1; then missing="$missing xorriso" fi + if ! command -v 7z >/dev/null 2>&1 && ! command -v 7za >/dev/null 2>&1; then + missing="$missing p7zip-full" + fi if [ -n "$missing" ]; then echo "❌ Missing required tools:$missing" @@ -79,6 +82,9 @@ check_tools() { if [[ "$missing" == *"xorriso"* ]]; then apt-get install -y xorriso fi + if [[ "$missing" == *"p7zip-full"* ]]; then + apt-get install -y p7zip-full + fi if [[ "$missing" == *"docker-or-podman"* ]]; then echo " Installing podman..." @@ -208,12 +214,24 @@ server { try_files $uri $uri/ /index.html; } + # Peer-to-peer node messaging (receives from other nodes over Tor) + location /archipelago/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy API requests to backend location /rpc/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Increase timeout for long-running operations (e.g., Docker image pulls) + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; } # Proxy WebSocket @@ -223,6 +241,7 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_read_timeout 86400s; } } NGINXCONF @@ -268,7 +287,7 @@ echo "📦 Step 2: Creating installer environment..." # Download Debian Live as our installer base BASE_ISO="$WORK_DIR/debian-live-installer.iso" -EXPECTED_SIZE=369000000 # ~352MB +EXPECTED_SIZE=1500000000 # ~1.5GB min (Debian 13 Live standard ~1.9GB) # Check if file exists and is complete if [ -f "$BASE_ISO" ]; then @@ -287,7 +306,7 @@ if [ ! -f "$BASE_ISO" ]; then # Use wget without -O so --continue actually works # Download with the ugly SourceForge filename, then rename - ISO_URL="https://sourceforge.net/projects/debian-live-respin-iso/files/standard/live-image-debian12.11-standard-20250522-amd64.hybrid.iso/download" + ISO_URL="https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.3.0-amd64-standard.iso" if command -v wget >/dev/null 2>&1; then cd "$WORK_DIR" @@ -302,8 +321,8 @@ if [ ! -f "$BASE_ISO" ]; then # Find the downloaded file (wget creates it with a name like "download" or the actual filename) if [ -f "download" ]; then mv "download" "$BASE_ISO" - elif [ -f "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" ]; then - mv "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" "$BASE_ISO" + elif [ -f "debian-live-13.3.0-amd64-standard.iso" ]; then + mv "debian-live-13.3.0-amd64-standard.iso" "$BASE_ISO" else echo " ❌ Downloaded file not found" exit 1 @@ -335,7 +354,10 @@ INSTALLER_ISO="$WORK_DIR/installer-iso" rm -rf "$INSTALLER_ISO" mkdir -p "$INSTALLER_ISO" cd "$INSTALLER_ISO" -7z x -y "$BASE_ISO" >/dev/null 2>&1 || 7z x -y "$BASE_ISO" +(7z x -y "$BASE_ISO" 2>/dev/null || 7za x -y "$BASE_ISO" 2>/dev/null || bsdtar -xf "$BASE_ISO" 2>/dev/null) || { + echo " ❌ Failed to extract ISO. Install p7zip-full: sudo apt install p7zip-full" + exit 1 +} # ============================================================================= # STEP 3: Add Archipelago components @@ -362,17 +384,15 @@ fi # Try to get from live server first (unless BUILD_FROM_SOURCE=1) BACKEND_CAPTURED=0 if [ "$BUILD_FROM_SOURCE" != "1" ]; then - # Check if we're running on the server itself (localhost or same machine) - if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then - # Direct copy from local filesystem - if [ -f "/usr/local/bin/archipelago" ]; then - cp "/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" - chmod +x "$ARCH_DIR/bin/archipelago" - echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" - BACKEND_CAPTURED=1 - fi - else - # Remote copy via SCP + # Direct copy from local filesystem (when running on target with sudo) + if [ -f "/usr/local/bin/archipelago" ]; then + cp "/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" + chmod +x "$ARCH_DIR/bin/archipelago" + echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" + BACKEND_CAPTURED=1 + fi + # Remote copy via SCP if local failed + if [ "$BACKEND_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then if scp "$DEV_SERVER:/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" 2>/dev/null; then chmod +x "$ARCH_DIR/bin/archipelago" echo " ✅ Backend captured from remote server ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))" @@ -416,16 +436,14 @@ mkdir -p "$ARCH_DIR/web-ui" # Try to get from live server first (unless BUILD_FROM_SOURCE=1) WEBUI_CAPTURED=0 if [ "$BUILD_FROM_SOURCE" != "1" ]; then - # Check if we're running on the server itself - if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then - # Direct copy from local filesystem - if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then - cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/" - echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" - WEBUI_CAPTURED=1 - fi - else - # Remote copy via rsync + # Direct copy from local filesystem (when running on target with sudo) + if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then + cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/" + echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" + WEBUI_CAPTURED=1 + fi + # Remote copy via rsync if local failed + if [ "$WEBUI_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then if rsync -az "$DEV_SERVER:/opt/archipelago/web-ui/" "$ARCH_DIR/web-ui/" 2>/dev/null && [ "$(ls -A "$ARCH_DIR/web-ui")" ]; then echo " ✅ Web UI captured from remote server ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))" WEBUI_CAPTURED=1 @@ -481,7 +499,7 @@ mkdir -p "$IMAGES_DIR" IMAGES_CAPTURED_FROM_SERVER=0 if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then echo " Capturing container images from live server ($DEV_SERVER)..." - CAPTURE_PATTERNS="bitcoin-ui bitcoin-knots lnd lnd-ui filebrowser mempool tailscale homeassistant btcpayserver nostr-rs-relay strfry" + CAPTURE_PATTERNS="bitcoin-ui bitcoin-knots lnd lnd-ui filebrowser mempool mempool-electrs tailscale homeassistant btcpayserver nostr-rs-relay strfry alpine-tor" REMOTE_TMP="/tmp/archipelago-image-capture-$$" SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(sudo podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && sudo podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true for p in $SAVED_LIST; do @@ -503,11 +521,16 @@ bitcoinknots/bitcoin:29 bitcoin-knots.tar lightninglabs/lnd:v0.18.4-beta lnd.tar ghcr.io/home-assistant/home-assistant:stable homeassistant.tar btcpayserver/btcpayserver:latest btcpayserver.tar -mempool/frontend:latest mempool.tar +mempool/frontend:latest mempool-frontend.tar +mempool/backend:v2.5.0 mempool-backend.tar +mempool/electrs:latest mempool-electrs.tar +docker.io/mariadb:10.11 mariadb-mempool.tar +docker.io/fedimint/fedimintd:v0.10.0 fedimint.tar docker.io/filebrowser/filebrowser:latest filebrowser.tar scsibug/nostr-rs-relay:latest nostr-rs-relay.tar hoytech/strfry:latest strfry.tar tailscale/tailscale:latest tailscale.tar +docker.io/andrius/alpine-tor:latest alpine-tor.tar " # Pull and save each image (force AMD64 for x86_64 target) only if not already present @@ -571,6 +594,9 @@ for tarfile in "$IMAGES_DIR"/*.tar; do fi done +# Ensure archy-net exists for mempool stack (db, api, frontend) +podman network create archy-net 2>/dev/null || true + echo "$(date): Container image load complete" >> "$LOG_FILE" echo "$(date): Available images:" >> "$LOG_FILE" podman images >> "$LOG_FILE" 2>&1 @@ -583,7 +609,85 @@ mkdir -p "$ARCH_DIR/scripts" cp "$WORK_DIR/load-container-images.sh" "$ARCH_DIR/scripts/" cp "$WORK_DIR/archipelago-load-images.service" "$ARCH_DIR/scripts/" -echo " ✅ Container images bundled" +# Tor setup: copy torrc and create first-boot setup script +mkdir -p "$ARCH_DIR/scripts/tor" +if [ -f "$SCRIPT_DIR/../scripts/tor/torrc.template" ]; then + cp "$SCRIPT_DIR/../scripts/tor/torrc.template" "$ARCH_DIR/scripts/tor/torrc" +fi + +echo " Creating first-boot Tor setup service..." +cat > "$WORK_DIR/archipelago-setup-tor.service" <<'TORSERVICE' +[Unit] +Description=Setup and start Archipelago Tor hidden services +After=archipelago-load-images.service network.target podman.service +ConditionPathExists=/opt/archipelago/scripts/setup-tor.sh + +[Service] +Type=oneshot +ExecStart=/opt/archipelago/scripts/setup-tor.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +TORSERVICE + +cat > "$WORK_DIR/setup-tor.sh" <<'TORSCRIPT' +#!/bin/bash +# Setup and start Tor container for unique .onion addresses (autoinstaller first-boot) + +TOR_DIR="/var/lib/archipelago/tor" +TORRC_SRC="/opt/archipelago/scripts/tor/torrc" +LOG="/var/log/archipelago-tor.log" + +mkdir -p "$TOR_DIR" +if [ -f "$TORRC_SRC" ]; then + cp "$TORRC_SRC" "$TOR_DIR/torrc" +fi +if [ ! -f "$TOR_DIR/torrc" ]; then + echo "SocksPort 9050" > "$TOR_DIR/torrc" + echo "ControlPort 0" >> "$TOR_DIR/torrc" + echo "DataDirectory $TOR_DIR" >> "$TOR_DIR/torrc" + echo "HiddenServiceDir $TOR_DIR/hidden_service_archipelago/" >> "$TOR_DIR/torrc" + echo "HiddenServicePort 80 127.0.0.1:80" >> "$TOR_DIR/torrc" +fi + +DOCKER=podman +command -v podman >/dev/null 2>&1 || DOCKER=docker + +for c in $(sudo $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor$'); do + [ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null +done + +if ! sudo $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-tor; then + if sudo $DOCKER run -d --name archy-tor --restart unless-stopped --network host \ + -v "$TOR_DIR:$TOR_DIR" \ + --entrypoint tor \ + docker.io/andrius/alpine-tor:latest \ + -f "$TOR_DIR/torrc" >> "$LOG" 2>&1; then + echo "$(date): Tor container started" >> "$LOG" + fi +fi +# Wait for Tor to create hostname files (~30-60s), then chmod so archipelago user can read +# (Backend runs as archipelago and needs node_address for Nostr peer discovery) +# Must chmod parent dirs (711=traverse) and hostname files (644) - Tor creates 700 dirs +for i in 1 2 3 4 5 6 7 8 9 10; do + sleep 6 + if [ -f "$TOR_DIR/hidden_service_archipelago/hostname" ]; then + chmod 711 "$TOR_DIR" "$TOR_DIR"/hidden_service_*/ + for f in "$TOR_DIR"/hidden_service_*/hostname; do + [ -f "$f" ] && chmod 644 "$f" && echo "$(date): chmod hostname $f" >> "$LOG" + done + echo "$(date): Tor hostname files readable by archipelago" >> "$LOG" + break + fi +done +TORSCRIPT + +chmod +x "$WORK_DIR/setup-tor.sh" +cp "$WORK_DIR/setup-tor.sh" "$ARCH_DIR/scripts/" +cp "$WORK_DIR/archipelago-setup-tor.service" "$ARCH_DIR/scripts/" + +echo " ✅ Container images bundled (including Tor)" # ============================================================================= # STEP 4: Create auto-installer script @@ -813,6 +917,17 @@ if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then if [ -f "$BOOT_MEDIA/archipelago/scripts/archipelago-load-images.service" ]; then cp "$BOOT_MEDIA/archipelago/scripts/archipelago-load-images.service" /mnt/target/etc/systemd/system/ fi + if [ -f "$BOOT_MEDIA/archipelago/scripts/setup-tor.sh" ]; then + cp "$BOOT_MEDIA/archipelago/scripts/setup-tor.sh" /mnt/target/opt/archipelago/scripts/ + chmod +x /mnt/target/opt/archipelago/scripts/setup-tor.sh + fi + if [ -d "$BOOT_MEDIA/archipelago/scripts/tor" ]; then + mkdir -p /mnt/target/opt/archipelago/scripts/tor + cp -r "$BOOT_MEDIA/archipelago/scripts/tor/"* /mnt/target/opt/archipelago/scripts/tor/ 2>/dev/null || true + fi + if [ -f "$BOOT_MEDIA/archipelago/scripts/archipelago-setup-tor.service" ]; then + cp "$BOOT_MEDIA/archipelago/scripts/archipelago-setup-tor.service" /mnt/target/etc/systemd/system/ + fi echo " ✅ Container images staged for first-boot loading" fi @@ -873,7 +988,7 @@ chmod +x /mnt/target/etc/profile.d/archipelago.sh cat > /mnt/target/etc/systemd/system/archipelago.service <<'SERVICE' [Unit] Description=Archipelago Backend -After=network-online.target +After=network-online.target archipelago-setup-tor.service Wants=network-online.target [Service] @@ -907,6 +1022,7 @@ chroot /mnt/target update-grub chroot /mnt/target systemctl enable archipelago.service 2>/dev/null || true chroot /mnt/target systemctl enable nginx.service 2>/dev/null || true chroot /mnt/target systemctl enable archipelago-load-images.service 2>/dev/null || true +chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true # Cleanup sync diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index f389febe..64441af9 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -8,6 +8,9 @@ Type=simple User=root Environment="ARCHIPELAGO_BIND=0.0.0.0:5678" Environment="ARCHIPELAGO_DEV_MODE=true" +# Host IP for container env vars (FM_P2P_URL, etc.) - detected at startup if unset +EnvironmentFile=-/etc/archipelago/host-ip.env +ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk \"{print \\$1}\")" > /etc/archipelago/host-ip.env' ExecStart=/usr/local/bin/archipelago Restart=on-failure RestartSec=5 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 08e0d0b9..9e2a6f13 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -11,6 +11,14 @@ server { try_files $uri $uri/ /index.html; } + # Peer-to-peer node messaging (receives from other nodes over Tor) + location /archipelago/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy API requests to backend location /rpc/ { proxy_pass http://127.0.0.1:5678; diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index c2deaedc..395a4589 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.su3n1rkrf7k" + "revision": "0.8432ene9gn8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index fac3ea58..07e7c3c1 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -140,6 +140,7 @@ async function getDockerContainers() { 'archy-morphos': 'morphos-server', 'archy-lnd': 'lightning-stack', 'archy-mempool-web': 'mempool', + 'mempool-electrs': 'mempool-electrs', 'archy-ollama': 'ollama', 'archy-searxng': 'searxng', 'archy-onlyoffice': 'onlyoffice', @@ -187,7 +188,7 @@ async function getDockerContainers() { }, 'fedimint': { title: 'Fedimint', - icon: '/assets/img/icon-fedimint.jpeg', + icon: '/assets/img/app-icons/fedimint.png', description: 'Federated Bitcoin mint' }, 'morphos-server': { @@ -205,6 +206,11 @@ async function getDockerContainers() { icon: '/assets/img/app-icons/mempool.png', description: 'Bitcoin blockchain explorer' }, + 'mempool-electrs': { + title: 'Electrs', + icon: '/assets/img/app-icons/electrs.svg', + description: 'Electrum protocol indexer for Bitcoin' + }, 'ollama': { title: 'Ollama', icon: '/assets/img/app-icons/ollama.png', @@ -623,13 +629,25 @@ app.post('/rpc/v1', (req, res) => { case 'auth.onboardingComplete': { userState.onboardingComplete = true console.log(`[Auth] Onboarding completed`) - return res.json({ result: { success: true } }) + return res.json({ result: true }) } case 'auth.isOnboardingComplete': { return res.json({ result: userState.onboardingComplete }) } + case 'node.did': { + const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' + const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' + return res.json({ result: { did: mockDid, pubkey: mockPubkey } }) + } + case 'node.nostr-publish': { + return res.json({ result: { event_id: 'mock-event-id', success: 2, failed: 0 } }) + } + case 'node.nostr-pubkey': { + return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } }) + } + case 'auth.login': { const { password } = params diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 79be018a..5cb71421 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dockerode": "^4.0.9", "fast-json-patch": "^3.1.1", + "fuse.js": "^7.1.0", "pinia": "^3.0.4", "vue": "^3.5.24", "vue-router": "^4.6.3" @@ -4878,6 +4879,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index ea85fde7..7c5f58d2 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -20,6 +20,7 @@ "dependencies": { "dockerode": "^4.0.9", "fast-json-patch": "^3.1.1", + "fuse.js": "^7.1.0", "pinia": "^3.0.4", "vue": "^3.5.24", "vue-router": "^4.6.3" diff --git a/neode-ui/public/assets/img/app-icons/README.md b/neode-ui/public/assets/img/app-icons/README.md new file mode 100644 index 00000000..7585dbf1 --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/README.md @@ -0,0 +1,11 @@ +# App Icons – Canonical Source + +**This is the single source of truth for all app icons.** + +- **Path**: `neode-ui/public/assets/img/app-icons/` +- **Naming**: `{app-id}.{ext}` (e.g. `fedimint.png`, `mempool.webp`, `lnd.svg`) +- **Formats**: PNG, WebP, or SVG (prefer WebP for size, SVG for scalability) + +All references in the codebase use `/assets/img/app-icons/{filename}`. Build outputs (web/dist, image-recipe) copy from here. + +To add an icon: place the file here with the app-id as the filename. Run `npm run build` to update deployed assets. diff --git a/neode-ui/public/assets/img/app-icons/electrs.svg b/neode-ui/public/assets/img/app-icons/electrs.svg new file mode 100644 index 00000000..ee5454ef --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/electrs.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/app-icons/icon-fedimint.jpeg b/neode-ui/public/assets/img/app-icons/fedimint.png similarity index 100% rename from neode-ui/public/assets/img/app-icons/icon-fedimint.jpeg rename to neode-ui/public/assets/img/app-icons/fedimint.png diff --git a/neode-ui/scripts/download-app-icons.js b/neode-ui/scripts/download-app-icons.js index 0e55646f..8ac1e2df 100755 --- a/neode-ui/scripts/download-app-icons.js +++ b/neode-ui/scripts/download-app-icons.js @@ -42,6 +42,13 @@ const repoMap = { 'penpot': 'penpot-startos', } +// Custom icon URLs for apps without Start9 repos +const customIconUrls = { + 'fedimint': [ + 'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg', + ], +} + const iconDir = path.join(__dirname, '../public/assets/img/app-icons') // Ensure directory exists @@ -82,36 +89,52 @@ function downloadFile(url, filepath) { } async function downloadIcon(appId) { - const repoName = repoMap[appId] || `${appId}-startos` - - // Try multiple icon paths - const iconPaths = [ - `icon.png`, - `icon.svg`, - `assets/icon.png`, - `assets/icon.svg`, - ] - - for (const iconPath of iconPaths) { - const url = `https://raw.githubusercontent.com/Start9Labs/${repoName}/main/${iconPath}` - const extension = iconPath.endsWith('.svg') ? 'svg' : 'png' - const filepath = path.join(iconDir, `${appId}.${extension}`) - - // Skip if file already exists - if (fs.existsSync(filepath)) { + const targetExt = 'webp' // Prefer webp for consistency with mempool, etc. + const fallbackExts = ['webp', 'png', 'svg'] + const filepath = path.join(iconDir, `${appId}.webp`) + + // Skip if file already exists + if (appId === 'fedimint' && fs.existsSync(path.join(iconDir, 'fedimint.png'))) { + console.log(`⏭️ Skipping ${appId} (fedimint.png exists)`) + return true + } + for (const ext of fallbackExts) { + const fp = path.join(iconDir, `${appId}.${ext}`) + if (fs.existsSync(fp)) { console.log(`⏭️ Skipping ${appId} (already exists)`) return true } - + } + + // Try custom URLs first (e.g. fedimint from fedimint-ui) + if (customIconUrls[appId]) { + for (const url of customIconUrls[appId]) { + try { + const ext = url.endsWith('.svg') ? 'svg' : (url.endsWith('.png') ? 'png' : 'webp') + const fp = path.join(iconDir, `${appId}.${ext}`) + await downloadFile(url, fp) + return true + } catch (err) { + continue + } + } + } + + const repoName = repoMap[appId] || `${appId}-startos` + const iconPaths = ['icon.png', 'icon.svg', 'assets/icon.png', 'assets/icon.svg'] + + for (const iconPath of iconPaths) { + const url = `https://raw.githubusercontent.com/Start9Labs/${repoName}/main/${iconPath}` + const extension = iconPath.endsWith('.svg') ? 'svg' : 'png' + const fp = path.join(iconDir, `${appId}.${extension}`) try { - await downloadFile(url, filepath) + await downloadFile(url, fp) return true } catch (err) { - // Try next path continue } } - + console.log(`❌ Failed to download icon for ${appId}`) return false } diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 7cb135d9..0b61dd01 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -6,18 +6,46 @@ + + + + + + diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 95a6e66b..7afc4570 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -77,6 +77,21 @@ class RPCClient { }) } + async changePassword(params: { + currentPassword: string + newPassword: string + alsoChangeSsh?: boolean + }): Promise<{ success: boolean }> { + return this.call({ + method: 'auth.changePassword', + params: { + currentPassword: params.currentPassword, + newPassword: params.newPassword, + alsoChangeSsh: params.alsoChangeSsh ?? true, + }, + }) + } + async logout(): Promise { return this.call({ method: 'auth.logout', @@ -84,6 +99,113 @@ class RPCClient { }) } + async completeOnboarding(): Promise { + return this.call({ + method: 'auth.onboardingComplete', + params: {}, + }) + } + + async isOnboardingComplete(): Promise { + return this.call({ + method: 'auth.isOnboardingComplete', + params: {}, + }) + } + + async getNodeDid(): Promise<{ did: string; pubkey: string }> { + return this.call({ + method: 'node.did', + params: {}, + }) + } + + async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> { + return this.call({ + method: 'node.nostr-publish', + params: {}, + }) + } + + async getNostrPubkey(): Promise<{ nostr_pubkey: string }> { + return this.call({ + method: 'node.nostr-pubkey', + params: {}, + }) + } + + async listPeers(): Promise<{ peers: Array<{ onion: string; pubkey: string; name?: string }> }> { + return this.call({ + method: 'node-list-peers', + params: {}, + }) + } + + async addPeer(params: { onion: string; pubkey: string; name?: string }): Promise<{ peers: unknown[] }> { + return this.call({ + method: 'node-add-peer', + params, + }) + } + + async removePeer(pubkey: string): Promise<{ peers: unknown[] }> { + return this.call({ + method: 'node-remove-peer', + params: { pubkey }, + }) + } + + async sendMessageToPeer(onion: string, message: string): Promise<{ ok: boolean; sent_to: string }> { + return this.call({ + method: 'node-send-message', + params: { onion, message }, + timeout: 90000, + }) + } + + async checkPeerReachable(onion: string): Promise<{ onion: string; reachable: boolean }> { + return this.call({ + method: 'node-check-peer', + params: { onion }, + timeout: 35000, + }) + } + + async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> { + return this.call({ + method: 'node-messages-received', + params: {}, + }) + } + + async discoverNodes(): Promise<{ nodes: Array<{ did: string; onion: string; pubkey: string; node_address: string }> }> { + return this.call({ + method: 'node-nostr-discover', + params: {}, + timeout: 20000, + }) + } + + async getTorAddress(): Promise<{ tor_address: string | null }> { + return this.call({ + method: 'node.tor-address', + params: {}, + }) + } + + async verifyNostrRevoked(): Promise<{ + revoked: boolean + nostr_pubkey: string + latest_content?: string + error?: string + }> { + return this.call({ + method: 'node-nostr-verify-revoked', + params: {}, + timeout: 25000, + }) + } + async echo(message: string): Promise { return this.call({ method: 'server.echo', diff --git a/neode-ui/src/components/AnimatedLogo.vue b/neode-ui/src/components/AnimatedLogo.vue new file mode 100644 index 00000000..ed4b2ad3 --- /dev/null +++ b/neode-ui/src/components/AnimatedLogo.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/neode-ui/src/components/ControllerIndicator.vue b/neode-ui/src/components/ControllerIndicator.vue new file mode 100644 index 00000000..b1407d82 --- /dev/null +++ b/neode-ui/src/components/ControllerIndicator.vue @@ -0,0 +1,21 @@ + + + diff --git a/neode-ui/src/components/HelpGuideModal.vue b/neode-ui/src/components/HelpGuideModal.vue new file mode 100644 index 00000000..ad4945c9 --- /dev/null +++ b/neode-ui/src/components/HelpGuideModal.vue @@ -0,0 +1,58 @@ + + + diff --git a/neode-ui/src/components/SpotlightSearch.vue b/neode-ui/src/components/SpotlightSearch.vue new file mode 100644 index 00000000..415fee3a --- /dev/null +++ b/neode-ui/src/components/SpotlightSearch.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts new file mode 100644 index 00000000..0baa6592 --- /dev/null +++ b/neode-ui/src/composables/useControllerNav.ts @@ -0,0 +1,170 @@ +/** + * Controller / gamepad-style navigation for Archipelago. + * Supports Rii X8 (keyboard/d-pad) and standard gamepads. + * - Arrow keys / d-pad: navigate between focusable elements + * - Enter / A button: activate + * - Escape / B button: back + * - Game-like navigation sounds and visual feedback + */ + +import { ref, onMounted, onBeforeUnmount, watch } from 'vue' +import { useControllerStore } from '@/stores/controller' +import { useSpotlightStore } from '@/stores/spotlight' + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[data-controller-focus]', +].join(', ') + +function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => !el.hasAttribute('disabled') && el.offsetParent !== null + ) +} + +function playNavSound(type: 'move' | 'select' | 'back' = 'move') { + try { + const ctx = new (window.AudioContext || (window as any).webkitAudioContext)() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.connect(gain) + gain.connect(ctx.destination) + gain.gain.value = 0.08 + osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440 + osc.type = 'sine' + osc.start() + osc.stop(ctx.currentTime + 0.05) + } catch { + // Audio not supported or blocked + } +} + +export function useControllerNav(containerRef?: { value: HTMLElement | null }) { + const store = useControllerStore() + const isControllerActive = ref(false) + const gamepadCount = ref(0) + + watch([isControllerActive, gamepadCount], () => { + store.setActive(isControllerActive.value) + store.setGamepadCount(gamepadCount.value) + }, { immediate: true }) + let keyNavTimeout: ReturnType | null = null + let pollIntervalId: ReturnType | null = null + + function checkGamepads() { + const gamepads = navigator.getGamepads?.() + const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 + if (count !== gamepadCount.value) { + gamepadCount.value = count + isControllerActive.value = count > 0 + } + } + + function handleKeyDown(e: KeyboardEvent) { + const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] + if (!navKeys.includes(e.key)) return + + // Ignore when typing in inputs + const target = e.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + if (e.key !== 'Escape') return + } + + const root = containerRef?.value ?? document + const focusable = getFocusableElements(root) + const currentIndex = focusable.indexOf(document.activeElement as HTMLElement) + + if (e.key === 'Escape') { + if (useSpotlightStore().isOpen) { + useSpotlightStore().close() + e.preventDefault() + e.stopPropagation() + return + } + playNavSound('back') + window.history.back() + e.preventDefault() + return + } + + if (e.key === 'Enter') { + if (currentIndex >= 0 && focusable[currentIndex]) { + playNavSound('select') + ;(focusable[currentIndex] as HTMLElement).click() + } + e.preventDefault() + return + } + + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + isControllerActive.value = true + if (keyNavTimeout) clearTimeout(keyNavTimeout) + keyNavTimeout = setTimeout(() => { + isControllerActive.value = gamepadCount.value > 0 + }, 3000) + + let nextIndex = currentIndex + const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight' + + if (focusable.length === 0) return + + if (currentIndex < 0) { + nextIndex = isForward ? 0 : focusable.length - 1 + } else { + nextIndex = isForward ? currentIndex + 1 : currentIndex - 1 + if (nextIndex < 0) nextIndex = focusable.length - 1 + if (nextIndex >= focusable.length) nextIndex = 0 + } + + const next = focusable[nextIndex] + if (next) { + playNavSound('move') + next.focus() + next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + } + } + } + + function handleGamepadInput() { + checkGamepads() + } + + function handleGamepadConnected() { + const gamepads = navigator.getGamepads?.() + gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1 + isControllerActive.value = true + } + + function handleGamepadDisconnected() { + const gamepads = navigator.getGamepads?.() + gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 + isControllerActive.value = gamepadCount.value > 0 + } + + onMounted(() => { + checkGamepads() + window.addEventListener('keydown', handleKeyDown, true) + window.addEventListener('gamepadconnected', handleGamepadConnected) + window.addEventListener('gamepaddisconnected', handleGamepadDisconnected) + pollIntervalId = setInterval(handleGamepadInput, 500) + }) + + onBeforeUnmount(() => { + window.removeEventListener('keydown', handleKeyDown, true) + window.removeEventListener('gamepadconnected', handleGamepadConnected) + window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected) + if (pollIntervalId) clearInterval(pollIntervalId) + if (keyNavTimeout) clearTimeout(keyNavTimeout) + }) + + return { + isControllerActive, + gamepadCount, + } +} diff --git a/neode-ui/src/composables/useMessageToast.ts b/neode-ui/src/composables/useMessageToast.ts new file mode 100644 index 00000000..a11b843f --- /dev/null +++ b/neode-ui/src/composables/useMessageToast.ts @@ -0,0 +1,86 @@ +import { ref, computed } from 'vue' +import { useRouter } from 'vue-router' +import { rpcClient } from '@/api/rpc-client' + +export interface ReceivedMessage { + from_pubkey: string + message: string + timestamp: string +} + +const MESSAGE_POLL_INTERVAL = 30000 // 30s + +// Shared state (singleton) so toast works across route changes +const receivedMessages = ref([]) +const lastMessageCount = ref(0) +const loadingMessages = ref(false) +const toastMessage = ref<{ show: boolean; text: string }>({ show: false, text: '' }) +let pollTimer: ReturnType | null = null + +export function useMessageToast() { + const router = useRouter() + + const unreadCount = computed(() => + Math.max(0, receivedMessages.value.length - lastMessageCount.value) + ) + + async function loadReceivedMessages() { + loadingMessages.value = true + try { + const res = await rpcClient.getReceivedMessages() + const msgs = (res.messages || []) as ReceivedMessage[] + receivedMessages.value = msgs + // New messages since last check? (don't show toast on initial load) + if (msgs.length > lastMessageCount.value && lastMessageCount.value > 0) { + const newCount = msgs.length - lastMessageCount.value + const latest = msgs[msgs.length - 1] + toastMessage.value = { + show: true, + text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`, + } + } else { + lastMessageCount.value = msgs.length + } + } catch (e) { + console.error('Failed to load messages:', e) + } finally { + loadingMessages.value = false + } + } + + function startPolling() { + if (pollTimer) return + loadReceivedMessages() + pollTimer = setInterval(loadReceivedMessages, MESSAGE_POLL_INTERVAL) + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + function markAsRead() { + lastMessageCount.value = receivedMessages.value.length + } + + function dismissToastAndOpenMessages() { + toastMessage.value = { show: false, text: '' } + markAsRead() + router.push({ path: '/dashboard/web5', query: { tab: 'messages' } }) + } + + return { + receivedMessages, + lastMessageCount, + loadingMessages, + toastMessage, + unreadCount, + loadReceivedMessages, + startPolling, + stopPolling, + markAsRead, + dismissToastAndOpenMessages, + } +} diff --git a/neode-ui/src/composables/useOnboarding.ts b/neode-ui/src/composables/useOnboarding.ts new file mode 100644 index 00000000..f57432e2 --- /dev/null +++ b/neode-ui/src/composables/useOnboarding.ts @@ -0,0 +1,20 @@ +/** + * Onboarding state - prefers backend, falls back to localStorage for mock/offline. + */ +import { rpcClient } from '@/api/rpc-client' + +export async function isOnboardingComplete(): Promise { + try { + return await rpcClient.isOnboardingComplete() + } catch { + return localStorage.getItem('neode_onboarding_complete') === '1' + } +} + +export async function completeOnboarding(): Promise { + try { + await rpcClient.completeOnboarding() + } finally { + localStorage.setItem('neode_onboarding_complete', '1') + } +} diff --git a/neode-ui/src/data/helpTree.ts b/neode-ui/src/data/helpTree.ts new file mode 100644 index 00000000..33355ba5 --- /dev/null +++ b/neode-ui/src/data/helpTree.ts @@ -0,0 +1,97 @@ +export interface HelpSection { + id: string + label: string + items: HelpItem[] +} + +export interface HelpItem { + id: string + label: string + path?: string + content?: string + relatedPath?: string +} + +export interface SearchableItem { + id: string + label: string + path?: string + type: 'navigate' | 'learn' | 'action' + section: string + content?: string + relatedPath?: string +} + +export const helpTree: HelpSection[] = [ + { + id: 'navigate', + label: 'Navigate', + items: [ + { id: 'home', label: 'Home', path: '/dashboard' }, + { id: 'apps', label: 'My Apps', path: '/dashboard/apps' }, + { id: 'marketplace', label: 'App Store', path: '/dashboard/marketplace' }, + { id: 'cloud', label: 'Cloud', path: '/dashboard/cloud' }, + { id: 'server', label: 'Network', path: '/dashboard/server' }, + { id: 'web5', label: 'Web5', path: '/dashboard/web5' }, + { id: 'settings', label: 'Settings', path: '/dashboard/settings' }, + ], + }, + { + id: 'learn', + label: 'Learn', + items: [ + { + id: 'bitcoin-basics', + label: 'Bitcoin Basics', + content: 'Bitcoin is a decentralized digital currency. Your node validates transactions and maintains the blockchain locally.', + relatedPath: '/dashboard/server', + }, + { + id: 'lightning-network', + label: 'Lightning Network', + content: 'Lightning enables instant, low-fee payments. Open channels with other nodes to send and receive payments off-chain.', + relatedPath: '/dashboard/apps', + }, + { + id: 'self-hosting', + label: 'Self-Hosting', + content: 'Archipelago runs your services locally. Your data stays on your hardware, giving you full control and privacy.', + relatedPath: '/dashboard', + }, + ], + }, + { + id: 'actions', + label: 'Actions', + items: [ + { id: 'install-app', label: 'Install an App', path: '/dashboard/marketplace' }, + { id: 'manage-apps', label: 'Manage My Apps', path: '/dashboard/apps' }, + { id: 'network-settings', label: 'Network Settings', path: '/dashboard/server' }, + { id: 'backup', label: 'Backup & Recovery', path: '/dashboard/settings' }, + ], + }, +] + +export function flattenForSearch(): SearchableItem[] { + const result: SearchableItem[] = [] + for (const section of helpTree) { + const type = + section.id === 'navigate' + ? 'navigate' + : section.id === 'learn' + ? 'learn' + : 'action' + for (const item of section.items) { + result.push({ + id: item.id, + label: item.label, + path: item.path, + type, + section: section.label, + content: item.content, + relatedPath: item.relatedPath, + }) + } + } + return result +} diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index aa625586..914dc63e 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -11,30 +11,7 @@ const router = createRouter({ children: [ { path: '', - redirect: (_to) => { - // Initial routing logic - determines first screen after splash - const devMode = import.meta.env.VITE_DEV_MODE - const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1' - // const isSetup = localStorage.getItem('neode_setup_complete') === '1' - - // Setup mode: go directly to login (original StartOS setup) - if (devMode === 'setup') { - return '/login' - } - - // Onboarding mode: go to experimental onboarding flow - if (devMode === 'onboarding') { - return seenOnboarding ? '/login' : '/onboarding/intro' - } - - // Existing user mode: go to login - if (devMode === 'existing') { - return '/login' - } - - // Default: check if user has completed onboarding - return seenOnboarding ? '/login' : '/onboarding/intro' - }, + component: () => import('../views/RootRedirect.vue'), }, { path: 'login', diff --git a/neode-ui/src/stores/app.ts b/neode-ui/src/stores/app.ts index 8af0aa2c..c61ddb58 100644 --- a/neode-ui/src/stores/app.ts +++ b/neode-ui/src/stores/app.ts @@ -164,6 +164,7 @@ export const useAppStore = defineStore('app', () => { 'update-progress': null, }, 'lan-address': null, + 'tor-address': null, unread: 0, 'wifi-ssids': [], 'zram-enabled': false, diff --git a/neode-ui/src/stores/controller.ts b/neode-ui/src/stores/controller.ts new file mode 100644 index 00000000..a505b81b --- /dev/null +++ b/neode-ui/src/stores/controller.ts @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useControllerStore = defineStore('controller', () => { + const isActive = ref(false) + const gamepadCount = ref(0) + + function setActive(active: boolean) { + isActive.value = active + } + + function setGamepadCount(count: number) { + gamepadCount.value = count + isActive.value = count > 0 + } + + return { + isActive, + gamepadCount, + setActive, + setGamepadCount, + } +}) diff --git a/neode-ui/src/stores/spotlight.ts b/neode-ui/src/stores/spotlight.ts new file mode 100644 index 00000000..28da74b4 --- /dev/null +++ b/neode-ui/src/stores/spotlight.ts @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' +import { ref, reactive } from 'vue' + +const RECENT_ITEMS_KEY = 'archipelago-spotlight-recent' +const MAX_RECENT_ITEMS = 8 + +export interface RecentItem { + id: string + label: string + path?: string + type: 'navigate' | 'learn' | 'action' + timestamp: number +} + +export const useSpotlightStore = defineStore('spotlight', () => { + const isOpen = ref(false) + const selectedIndex = ref(0) + + const recentItems = ref([]) + + function loadRecentItems() { + try { + const raw = localStorage.getItem(RECENT_ITEMS_KEY) + if (raw) { + const parsed = JSON.parse(raw) as RecentItem[] + recentItems.value = parsed.slice(0, MAX_RECENT_ITEMS) + } else { + recentItems.value = [] + } + } catch { + recentItems.value = [] + } + } + + function addRecentItem(item: Omit) { + const withTimestamp: RecentItem = { ...item, timestamp: Date.now() } + const filtered = recentItems.value.filter( + (r) => !(r.id === item.id && r.type === item.type) + ) + recentItems.value = [withTimestamp, ...filtered].slice(0, MAX_RECENT_ITEMS) + try { + localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(recentItems.value)) + } catch { + // Ignore storage errors + } + } + + function open() { + isOpen.value = true + selectedIndex.value = 0 + loadRecentItems() + } + + function close() { + isOpen.value = false + selectedIndex.value = 0 + } + + function toggle() { + isOpen.value ? close() : open() + } + + function setSelectedIndex(index: number) { + selectedIndex.value = index + } + + const helpModal = reactive({ + show: false, + title: '', + content: '', + relatedPath: undefined as string | undefined, + }) + + function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) { + helpModal.show = true + helpModal.title = payload.title + helpModal.content = payload.content + helpModal.relatedPath = payload.relatedPath + } + + function closeHelpModal() { + helpModal.show = false + } + + return { + isOpen, + selectedIndex, + recentItems, + open, + close, + toggle, + setSelectedIndex, + addRecentItem, + loadRecentItems, + helpModal, + showHelpModal, + closeHelpModal, + } +}) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 79aae3f3..7ab3ccd0 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -2,6 +2,12 @@ @tailwind components; @tailwind utilities; +/* Controller / keyboard navigation - game-like focus ring */ +*:focus-visible { + outline: 2px solid rgba(251, 191, 36, 0.8); + outline-offset: 2px; +} + /* Global glassmorphism utilities */ @layer components { .glass { @@ -32,6 +38,13 @@ } .glass-button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 48px; + min-height: 48px; + padding-block: 0 !important; + line-height: 48px; background-color: rgba(0, 0, 0, 0.6); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); @@ -39,6 +52,34 @@ color: rgba(255, 255, 255, 0.9); } + .glass-button-sm { + min-height: 0 !important; + height: auto !important; + line-height: inherit; + padding-block: 0.375rem !important; + padding-inline: 0.75rem; + } + + /* Toast - glassmorphic, top-right */ + .toast-glass { + background-color: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + } + + /* Toast transition */ + .toast-enter-active, + .toast-leave-active { + transition: all 0.3s ease; + } + .toast-enter-from, + .toast-leave-to { + opacity: 0; + transform: translateX(1rem); + } + /* Gradient containers - transparent to black */ .gradient-card { background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%); @@ -91,7 +132,8 @@ z-index: 0; } - .logo-gradient-border img { + .logo-gradient-border img, + .logo-gradient-border svg { border-radius: 9999px; display: block; position: relative; diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index c313312f..0f493a92 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -13,6 +13,8 @@ export interface ServerInfo { pubkey: string 'status-info': StatusInfo 'lan-address': string | null + 'tor-address': string | null + 'node-address'?: string unread: number 'wifi-ssids': string[] 'zram-enabled': boolean @@ -48,6 +50,7 @@ export const PackageState = { Installed: 'installed', Stopping: 'stopping', Stopped: 'stopped', + Exited: 'exited', Starting: 'starting', Running: 'running', Restarting: 'restarting', diff --git a/neode-ui/src/utils/dummyApps.ts b/neode-ui/src/utils/dummyApps.ts index cfb42e57..eabd5692 100644 --- a/neode-ui/src/utils/dummyApps.ts +++ b/neode-ui/src/utils/dummyApps.ts @@ -191,7 +191,7 @@ export const dummyApps: Record = { 'static-files': { license: 'MIT', instructions: 'Federated Bitcoin mint', - icon: '/assets/img/icon-fedimint.jpeg' + icon: '/assets/img/app-icons/fedimint.png' }, manifest: { id: 'fedimint', @@ -216,7 +216,7 @@ export const dummyApps: Record = { 'interface-addresses': { main: { 'tor-address': 'fedimint.onion', - 'lan-address': 'http://localhost:8173' + 'lan-address': 'http://localhost:8175' } }, status: ServiceStatus.Running diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 3245b986..0406002b 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -268,6 +268,39 @@ + +
+

Access

+
+ +
+ + + +
+

Tor

+ {{ torUrl }} +

Requires Tor Browser

+
+
+
+
+

Requirements

@@ -407,17 +440,69 @@ const route = useRoute() const store = useAppStore() const appId = computed(() => route.params.id as string) -// Check both store.packages and dummyApps + +/** Map route/marketplace app IDs to backend package keys (container names). */ +const ROUTE_TO_PACKAGE_KEY: Record = { + mempool: 'mempool-web', + 'mempool-electrs': 'mempool-electrs', + electrs: 'mempool-electrs', + btcpay: 'btcpay-server', + 'btcpay-server': 'btcpay-server', + fedimint: 'fedimint', + lnd: 'lnd', + 'lnd-ui': 'lnd', + bitcoin: 'bitcoin-knots', + 'bitcoin-knots': 'bitcoin-knots', +} + +function resolvePackageKey(routeId: string): string { + return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId +} + +// Check both store.packages and dummyApps; resolve route ID to package key for backend data const pkg = computed(() => { - // First check real packages - if (store.packages[appId.value]) { - return store.packages[appId.value] + const routeId = appId.value + const packageKey = resolvePackageKey(routeId) + // First check real packages (try both route id and resolved key) + if (store.packages[packageKey]) { + return store.packages[packageKey] + } + if (store.packages[routeId]) { + return store.packages[routeId] } // Fall back to dummy apps - if (dummyApps[appId.value]) { - return dummyApps[appId.value] + if (dummyApps[routeId]) { + return dummyApps[routeId] } - return undefined + return null +}) + +const interfaceAddresses = computed(() => { + const main = pkg.value?.installed?.['interface-addresses']?.main + if (!main) return null + if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null + return main +}) + +/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */ +function isRealOnionAddress(addr: string | undefined): boolean { + return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70) +} + +const lanUrl = computed(() => { + const addr = interfaceAddresses.value?.['lan-address'] + if (!addr) return '#' + if (addr.includes('localhost')) { + return addr.replace('localhost', window.location.hostname) + } + return addr +}) + +/** Tor URL with http:// prefix for copy-paste into Tor Browser */ +const torUrl = computed(() => { + const addr = interfaceAddresses.value?.['tor-address'] + if (!addr || !isRealOnionAddress(addr)) return '' + return addr.startsWith('http') ? addr : `http://${addr}` }) const uninstallModal = ref({ @@ -543,8 +628,8 @@ function launchApp() { prod: 'http://localhost:8080' }, 'fedimint': { - dev: 'http://localhost:8173', - prod: 'http://localhost:8173' + dev: 'http://localhost:8175', + prod: 'http://192.168.1.228:8175' }, 'morphos-server': { dev: 'http://localhost:8081', diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 0c7bfaf1..7e1ebdce 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -27,7 +27,7 @@
@@ -48,8 +48,8 @@ class="w-16 h-16 rounded-lg object-cover bg-white/10" @error="handleImageError" /> -
-

+
+

{{ pkg.manifest.title }}

diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 4d2780e6..906f6e48 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -44,11 +44,9 @@