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.
This commit is contained in:
parent
6035c93289
commit
1073d9fd2c
@ -5,10 +5,24 @@ alwaysApply: true
|
|||||||
|
|
||||||
# Archipelago Development Workflow
|
# 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
|
## 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.
|
**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)
|
### 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`.
|
- **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).
|
- 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`
|
- Backend: `/usr/local/bin/archipelago`
|
||||||
4. Restarts services (systemd + nginx)
|
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
|
### 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
|
- **OS**: Debian-based server
|
||||||
- **Container Runtime**: Podman (root context for system services)
|
- **Container Runtime**: Podman (root context for system services)
|
||||||
- **Web Server**: Nginx
|
- **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`
|
- Systemd service: `/etc/systemd/system/archipelago.service`
|
||||||
- Nginx config: `/etc/nginx/sites-available/archipelago`
|
- 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
|
## Testing Workflow
|
||||||
|
|
||||||
1. Make changes locally
|
1. Make changes locally
|
||||||
2. Deploy with `--live` flag
|
2. Deploy with `--live` flag
|
||||||
3. Test at http://192.168.1.228
|
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'`
|
- 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'`
|
- Nginx: `ssh archipelago@192.168.1.228 'sudo tail -f /var/log/nginx/error.log'`
|
||||||
5. **Sync changes back to ISO build** (see below)
|
5. **Sync changes back to ISO build** (see below)
|
||||||
@ -82,6 +125,47 @@ Common containers:
|
|||||||
- Bitcoin Knots (ports 8332, 8333)
|
- Bitcoin Knots (ports 8332, 8333)
|
||||||
- LND (ports 9735, 10009)
|
- 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
|
## ISO Build Integration
|
||||||
|
|
||||||
**CRITICAL**: After testing on the live server, always update the ISO build to include your changes.
|
**CRITICAL**: After testing on the live server, always update the ISO build to include your changes.
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,6 +30,7 @@ build/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
scripts/deploy-config.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
app:
|
app:
|
||||||
id: fedimint
|
id: fedimint
|
||||||
name: Fedimint
|
name: Fedimint
|
||||||
version: 0.3.0
|
version: 0.10.0
|
||||||
description: Federated Bitcoin minting service. Privacy-preserving Bitcoin custody.
|
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedimint/fedimint:0.3.0
|
image: fedimint/fedimintd:v0.10.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
@ -28,10 +28,13 @@ app:
|
|||||||
ports:
|
ports:
|
||||||
- host: 8173
|
- host: 8173
|
||||||
container: 8173
|
container: 8173
|
||||||
protocol: tcp # API
|
protocol: tcp # P2P
|
||||||
- host: 8174
|
- host: 8174
|
||||||
container: 8174
|
container: 8174
|
||||||
protocol: tcp # Web UI
|
protocol: tcp # API
|
||||||
|
- host: 8175
|
||||||
|
container: 8175
|
||||||
|
protocol: tcp # Built-in Guardian UI
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
@ -40,15 +43,17 @@ app:
|
|||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- FM_BITCOIND_RPC=http://bitcoin-core:8332
|
- FM_DATA_DIR=/fedimint
|
||||||
- FM_BITCOIND_RPC_USER=${BITCOIN_RPC_USER}
|
- FM_BITCOIND_URL=http://bitcoin-core:8332
|
||||||
- FM_BITCOIND_RPC_PASS=${BITCOIN_RPC_PASSWORD}
|
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
|
||||||
- FM_NETWORK=mainnet
|
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||||
|
- FM_BITCOIN_NETWORK=bitcoin
|
||||||
|
- FM_BIND_UI=0.0.0.0:8175
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8174
|
endpoint: http://localhost:8175
|
||||||
path: /health
|
path: /
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
749
core/Cargo.lock
generated
749
core/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -43,12 +43,22 @@ archipelago-parmanode = { path = "../parmanode" }
|
|||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
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
|
# Configuration
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
# HTTP client (for LND REST proxy)
|
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "socks"] }
|
||||||
|
|
||||||
|
# Nostr (node discovery)
|
||||||
|
nostr-sdk = "0.44"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::electrs_status;
|
||||||
|
use crate::node_message as node_msg;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@ -20,7 +22,7 @@ pub struct ApiHandler {
|
|||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
||||||
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 {
|
Ok(Self {
|
||||||
_config: config,
|
_config: config,
|
||||||
@ -45,7 +47,7 @@ impl ApiHandler {
|
|||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
.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);
|
debug!("{} {}", method, path);
|
||||||
|
|
||||||
@ -55,6 +57,10 @@ impl ApiHandler {
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.body(hyper::Body::from("OK"))
|
.body(hyper::Body::from("OK"))
|
||||||
.unwrap()),
|
.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") => {
|
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path).await
|
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<Response<hyper::Body>> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Incoming {
|
||||||
|
from_pubkey: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||||
|
from_pubkey: None,
|
||||||
|
message: None,
|
||||||
|
});
|
||||||
|
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
||||||
|
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<Response<hyper::Body>> {
|
||||||
|
let status = electrs_status::get_electrs_sync_status().await;
|
||||||
|
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Access-Control-Allow-Origin", CORS_ANY)
|
||||||
|
.body(hyper::Body::from(body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_lnd_proxy(path: &str) -> Result<Response<hyper::Body>> {
|
async fn handle_lnd_proxy(path: &str) -> Result<Response<hyper::Body>> {
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
use crate::auth::AuthManager;
|
use crate::auth::AuthManager;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::container::docker_packages;
|
||||||
use crate::container::DevContainerOrchestrator;
|
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 anyhow::{Context, Result};
|
||||||
use hyper::{Request, Response, StatusCode};
|
use hyper::{Request, Response, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -33,10 +40,12 @@ pub struct RpcHandler {
|
|||||||
config: Config,
|
config: Config,
|
||||||
auth_manager: AuthManager,
|
auth_manager: AuthManager,
|
||||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub async fn new(config: Config) -> Result<Self> {
|
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
||||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||||
let orchestrator = if config.dev_mode {
|
let orchestrator = if config.dev_mode {
|
||||||
Some(Arc::new(
|
Some(Arc::new(
|
||||||
@ -45,11 +54,14 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
auth_manager,
|
auth_manager,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
|
state_manager,
|
||||||
|
port_allocator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +85,9 @@ impl RpcHandler {
|
|||||||
"server.echo" => self.handle_echo(rpc_req.params).await,
|
"server.echo" => self.handle_echo(rpc_req.params).await,
|
||||||
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
||||||
"auth.logout" => self.handle_auth_logout().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 orchestration (for Archipelago-managed containers)
|
||||||
"container-install" => self.handle_container_install(rpc_req.params).await,
|
"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.start" => self.handle_package_start(rpc_req.params).await,
|
||||||
"package.stop" => self.handle_package_stop(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.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 management (for pre-loaded container images)
|
||||||
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
|
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
|
||||||
"bundled-app-stop" => self.handle_bundled_app_stop(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))
|
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||||
}
|
}
|
||||||
@ -174,6 +204,103 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_auth_change_password(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
self.auth_manager.complete_onboarding().await?;
|
||||||
|
Ok(serde_json::json!(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||||
|
Ok(serde_json::json!(complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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(
|
async fn handle_container_install(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@ -525,17 +652,34 @@ impl RpcHandler {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// App-specific configuration (should come from manifest)
|
// 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 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 {
|
if is_tailscale {
|
||||||
run_args.push("--network=host");
|
run_args.push("--network=host");
|
||||||
run_args.push("--privileged");
|
run_args.push("--privileged");
|
||||||
run_args.push("--cap-add=NET_ADMIN");
|
run_args.push("--cap-add=NET_ADMIN");
|
||||||
run_args.push("--cap-add=NET_RAW");
|
run_args.push("--cap-add=NET_RAW");
|
||||||
run_args.push("--device=/dev/net/tun");
|
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
|
// Create data directories if they don't exist
|
||||||
@ -591,9 +735,11 @@ impl RpcHandler {
|
|||||||
let mut cmd = tokio::process::Command::new("sudo");
|
let mut cmd = tokio::process::Command::new("sudo");
|
||||||
cmd.args(&run_args);
|
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 {
|
if let Some(custom_cmd) = custom_command {
|
||||||
cmd.arg(custom_cmd);
|
cmd.arg(custom_cmd);
|
||||||
|
} else if let Some(args) = custom_args {
|
||||||
|
cmd.args(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
let run_output = cmd
|
let run_output = cmd
|
||||||
@ -627,35 +773,22 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
// But also check if container exists without the prefix
|
let to_start: Vec<String> = if containers.is_empty() {
|
||||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
vec![format!("archy-{}", package_id)]
|
||||||
.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 {
|
} 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() {
|
for name in to_start {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let _ = tokio::process::Command::new("sudo")
|
||||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
.args(["podman", "start", &name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
@ -671,34 +804,22 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
if containers.is_empty() {
|
||||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
// Fallback: try single container
|
||||||
.output()
|
let container_name = format!("archy-{}", package_id);
|
||||||
.await
|
let _ = tokio::process::Command::new("sudo")
|
||||||
{
|
.args(["podman", "stop", &container_name])
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
.output()
|
||||||
if !stdout.trim().is_empty() {
|
.await;
|
||||||
debug!("Found container without prefix: {}", package_id);
|
return Ok(serde_json::Value::Null);
|
||||||
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")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
for name in containers {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let _ = tokio::process::Command::new("sudo")
|
||||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
.args(["podman", "stop", &name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
@ -714,39 +835,74 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
|
||||||
// Convert package ID to container name
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
if containers.is_empty() {
|
||||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
let container_name = format!("archy-{}", package_id);
|
||||||
.output()
|
let _ = tokio::process::Command::new("sudo")
|
||||||
.await
|
.args(["podman", "restart", &container_name])
|
||||||
{
|
.output()
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
.await;
|
||||||
if !stdout.trim().is_empty() {
|
return Ok(serde_json::Value::Null);
|
||||||
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")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
for name in containers {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let _ = tokio::process::Command::new("sudo")
|
||||||
return Err(anyhow::anyhow!("Failed to restart container: {}", stderr));
|
.args(["podman", "restart", &name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::Value::Null)
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
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)
|
/// Start a bundled app (create container from pre-loaded image if needed, then start)
|
||||||
async fn handle_bundled_app_start(
|
async fn handle_bundled_app_start(
|
||||||
&self,
|
&self,
|
||||||
@ -858,6 +1014,150 @@ impl RpcHandler {
|
|||||||
|
|
||||||
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_node_add_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
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<serde_json::Value> {
|
||||||
|
let messages = node_message::get_received();
|
||||||
|
Ok(serde_json::json!({ "messages": messages }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<String> = 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<String> {
|
||||||
|
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
|
/// Validate Docker image name format
|
||||||
@ -886,106 +1186,204 @@ fn is_valid_docker_image(image: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get app-specific configuration
|
/// 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
|
/// TODO: Load from manifest.yml files in apps/ directory
|
||||||
fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>) {
|
fn get_app_config(
|
||||||
|
app_id: &str,
|
||||||
|
host_ip: &str,
|
||||||
|
allocator: &mut PortAllocator,
|
||||||
|
) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>, Option<Vec<String>>) {
|
||||||
match app_id {
|
match app_id {
|
||||||
"homeassistant" | "home-assistant" => (
|
"homeassistant" | "home-assistant" => (
|
||||||
vec!["8123:8123".to_string()],
|
vec!["8123:8123".to_string()],
|
||||||
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
||||||
vec!["TZ=UTC".to_string()],
|
vec!["TZ=UTC".to_string()],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"bitcoin" | "bitcoin-core" => (
|
"bitcoin" | "bitcoin-core" => (
|
||||||
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
||||||
vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()],
|
vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"lnd" => (
|
"lnd" => (
|
||||||
vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()],
|
vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()],
|
||||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||||
vec!["BITCOIN_ACTIVE=1".to_string()],
|
vec!["BITCOIN_ACTIVE=1".to_string()],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"btcpay-server" | "btcpayserver" => (
|
"btcpay-server" | "btcpayserver" => (
|
||||||
vec!["23000:49392".to_string()],
|
vec!["23000:49392".to_string()],
|
||||||
vec!["/var/lib/archipelago/btcpay:/datadir".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,
|
None,
|
||||||
),
|
),
|
||||||
"mempool" => (
|
"mempool" | "mempool-web" => (
|
||||||
vec!["8999:8080".to_string()],
|
vec!["4080:8080".to_string()],
|
||||||
vec![],
|
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![],
|
vec![],
|
||||||
None,
|
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" => (
|
"grafana" => (
|
||||||
vec!["3000:3000".to_string()],
|
vec!["3000:3000".to_string()],
|
||||||
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"searxng" => (
|
"searxng" => (
|
||||||
vec!["8888:8080".to_string()],
|
vec!["8888:8080".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"ollama" => (
|
"ollama" => (
|
||||||
vec!["11434:11434".to_string()],
|
vec!["11434:11434".to_string()],
|
||||||
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"onlyoffice" | "onlyoffice-documentserver" => (
|
"onlyoffice" | "onlyoffice-documentserver" => (
|
||||||
vec!["9980:80".to_string()],
|
vec!["9980:80".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"penpot" | "penpot-frontend" => (
|
"penpot" | "penpot-frontend" => (
|
||||||
vec!["9001:80".to_string()],
|
vec!["9001:80".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
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,
|
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" => (
|
"jellyfin" => (
|
||||||
vec!["8096:8096".to_string()],
|
vec!["8096:8096".to_string()],
|
||||||
vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()],
|
vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"photoprism" => (
|
"photoprism" => (
|
||||||
vec!["2342:2342".to_string()],
|
vec!["2342:2342".to_string()],
|
||||||
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"immich" => (
|
"immich" => (
|
||||||
vec!["2283:3001".to_string()],
|
vec!["2283:3001".to_string()],
|
||||||
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
),
|
|
||||||
"filebrowser" => (
|
|
||||||
vec!["8083:80".to_string()],
|
|
||||||
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
|
|
||||||
vec![],
|
|
||||||
None,
|
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" => (
|
"nginx-proxy-manager" => (
|
||||||
vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()],
|
vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()],
|
||||||
vec![
|
vec![
|
||||||
@ -994,18 +1392,21 @@ fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Optio
|
|||||||
],
|
],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"portainer" => (
|
"portainer" => (
|
||||||
vec!["9000:9000".to_string()],
|
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!["/var/lib/archipelago/portainer:/data".to_string(), "/var/run/podman/podman.sock:/var/run/docker.sock".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"uptime-kuma" => (
|
"uptime-kuma" => (
|
||||||
vec!["3001:3001".to_string()],
|
vec!["3001:3001".to_string()],
|
||||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
"tailscale" => (
|
"tailscale" => (
|
||||||
vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network)
|
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<String>, Vec<String>, Vec<String>, Optio
|
|||||||
"TS_STATE_DIR=/var/lib/tailscale".to_string(),
|
"TS_STATE_DIR=/var/lib/tailscale".to_string(),
|
||||||
],
|
],
|
||||||
Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".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,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct OnboardingState {
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
@ -43,13 +48,16 @@ impl AuthManager {
|
|||||||
|
|
||||||
pub async fn setup_user(&self, password: &str) -> Result<()> {
|
pub async fn setup_user(&self, password: &str) -> Result<()> {
|
||||||
use bcrypt::{hash, DEFAULT_COST};
|
use bcrypt::{hash, DEFAULT_COST};
|
||||||
|
|
||||||
let password_hash = hash(password, 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 {
|
let user = User {
|
||||||
password_hash,
|
password_hash,
|
||||||
setup_complete: true,
|
setup_complete: true,
|
||||||
onboarding_complete: false,
|
onboarding_complete,
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
@ -60,6 +68,15 @@ impl AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn complete_onboarding(&self) -> Result<()> {
|
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? {
|
if let Some(mut user) = self.get_user().await? {
|
||||||
user.onboarding_complete = true;
|
user.onboarding_complete = true;
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
@ -69,6 +86,25 @@ impl AuthManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_onboarding_complete(&self) -> Result<bool> {
|
||||||
|
// 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::<OnboardingState>(&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<bool> {
|
pub async fn verify_password(&self, password: &str) -> Result<bool> {
|
||||||
use bcrypt::verify;
|
use bcrypt::verify;
|
||||||
|
|
||||||
@ -78,4 +114,113 @@ impl AuthManager {
|
|||||||
Ok(false)
|
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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,15 +45,40 @@ pub struct Config {
|
|||||||
pub bind_host: String,
|
pub bind_host: String,
|
||||||
pub bind_port: u16,
|
pub bind_port: u16,
|
||||||
pub log_level: String,
|
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
|
// Dev mode configuration
|
||||||
pub dev_mode: bool,
|
pub dev_mode: bool,
|
||||||
pub container_runtime: ContainerRuntime,
|
pub container_runtime: ContainerRuntime,
|
||||||
pub port_offset: u16,
|
pub port_offset: u16,
|
||||||
pub bitcoin_simulation: BitcoinSimulation,
|
pub bitcoin_simulation: BitcoinSimulation,
|
||||||
pub dev_data_dir: PathBuf,
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
/// Detect primary host IP (first non-loopback IPv4)
|
||||||
|
fn detect_host_ip() -> Result<String> {
|
||||||
|
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<Self> {
|
pub async fn load() -> Result<Self> {
|
||||||
// Default configuration
|
// Default configuration
|
||||||
let mut config = Self::default();
|
let mut config = Self::default();
|
||||||
@ -124,6 +149,29 @@ impl Config {
|
|||||||
config.dev_data_dir = PathBuf::from(dev_data_dir);
|
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
|
// Ensure data directory exists
|
||||||
fs::create_dir_all(&config.data_dir).await
|
fs::create_dir_all(&config.data_dir).await
|
||||||
.context("Failed to create data directory")?;
|
.context("Failed to create data directory")?;
|
||||||
@ -145,11 +193,18 @@ impl Default for Config {
|
|||||||
bind_host: "0.0.0.0".to_string(),
|
bind_host: "0.0.0.0".to_string(),
|
||||||
bind_port: 5678,
|
bind_port: 5678,
|
||||||
log_level: "info".to_string(),
|
log_level: "info".to_string(),
|
||||||
|
host_ip: "127.0.0.1".to_string(),
|
||||||
dev_mode: false,
|
dev_mode: false,
|
||||||
container_runtime: ContainerRuntime::Auto,
|
container_runtime: ContainerRuntime::Auto,
|
||||||
port_offset: 10000,
|
port_offset: 10000,
|
||||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,11 +53,15 @@ impl DockerPackageScanner {
|
|||||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||||
for container in &containers {
|
for container in &containers {
|
||||||
if container.name.ends_with("-ui") {
|
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 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 !container.ports.is_empty() {
|
||||||
if let Some(ui_address) = extract_lan_address(&container.ports) {
|
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
|
// But web UI is always on port 8240
|
||||||
debug!("Tailscale detected, using port 8240");
|
debug!("Tailscale detected, using port 8240");
|
||||||
Some("http://localhost:8240".to_string())
|
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 {
|
} else {
|
||||||
// Extract port from the main container
|
// Extract port from the main container
|
||||||
extract_lan_address(&container.ports)
|
extract_lan_address(&container.ports)
|
||||||
@ -119,6 +131,8 @@ impl DockerPackageScanner {
|
|||||||
// Convert container state to package/service state
|
// Convert container state to package/service state
|
||||||
let (package_state, service_status) = convert_state(&container.state);
|
let (package_state, service_status) = convert_state(&container.state);
|
||||||
|
|
||||||
|
let tor_address = read_tor_address(&app_id);
|
||||||
|
|
||||||
let package = PackageDataEntry {
|
let package = PackageDataEntry {
|
||||||
state: package_state.clone(),
|
state: package_state.clone(),
|
||||||
static_files: StaticFiles {
|
static_files: StaticFiles {
|
||||||
@ -143,11 +157,11 @@ impl DockerPackageScanner {
|
|||||||
donation_url: None,
|
donation_url: None,
|
||||||
author: Some("Archipelago".to_string()),
|
author: Some("Archipelago".to_string()),
|
||||||
website: lan_address.clone(),
|
website: lan_address.clone(),
|
||||||
interfaces: if lan_address.is_some() {
|
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||||
Some(Interfaces {
|
Some(Interfaces {
|
||||||
main: Some(MainInterface {
|
main: Some(MainInterface {
|
||||||
ui: Some("true".to_string()),
|
ui: Some("true".to_string()),
|
||||||
tor_config: None,
|
tor_config: tor_address.clone(),
|
||||||
lan_config: None,
|
lan_config: None,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -159,13 +173,17 @@ impl DockerPackageScanner {
|
|||||||
current_dependents: HashMap::new(),
|
current_dependents: HashMap::new(),
|
||||||
current_dependencies: HashMap::new(),
|
current_dependencies: HashMap::new(),
|
||||||
last_backup: None,
|
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();
|
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(
|
addresses.insert(
|
||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
InterfaceAddress {
|
InterfaceAddress {
|
||||||
tor_address: format!("{}.onion", app_id),
|
tor_address: tor,
|
||||||
lan_address: Some(addr),
|
lan_address: lan_address,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
addresses
|
addresses
|
||||||
@ -227,7 +245,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
|||||||
"fedimint" => AppMetadata {
|
"fedimint" => AppMetadata {
|
||||||
title: "Fedimint".to_string(),
|
title: "Fedimint".to_string(),
|
||||||
description: "Federated Bitcoin mint".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(),
|
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||||
},
|
},
|
||||||
"morphos" | "morphos-server" => AppMetadata {
|
"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(),
|
icon: "/assets/img/app-icons/mempool.webp".to_string(),
|
||||||
repo: "https://github.com/mempool/mempool".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 {
|
"ollama" => AppMetadata {
|
||||||
title: "Ollama".to_string(),
|
title: "Ollama".to_string(),
|
||||||
description: "Run large language models locally".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<String> {
|
||||||
|
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<String> {
|
fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||||
for port_str in ports {
|
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"
|
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
||||||
|
|||||||
@ -22,6 +22,10 @@ pub struct ServerInfo {
|
|||||||
pub status_info: StatusInfo,
|
pub status_info: StatusInfo,
|
||||||
#[serde(rename = "lan-address")]
|
#[serde(rename = "lan-address")]
|
||||||
pub lan_address: Option<String>,
|
pub lan_address: Option<String>,
|
||||||
|
#[serde(rename = "tor-address")]
|
||||||
|
pub tor_address: Option<String>,
|
||||||
|
#[serde(rename = "node-address", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub node_address: Option<String>,
|
||||||
pub unread: u32,
|
pub unread: u32,
|
||||||
#[serde(rename = "wifi-ssids")]
|
#[serde(rename = "wifi-ssids")]
|
||||||
pub wifi_ssids: Vec<String>,
|
pub wifi_ssids: Vec<String>,
|
||||||
@ -225,6 +229,8 @@ impl DataModel {
|
|||||||
update_progress: None,
|
update_progress: None,
|
||||||
},
|
},
|
||||||
lan_address: Some("http://localhost:8100".to_string()),
|
lan_address: Some("http://localhost:8100".to_string()),
|
||||||
|
tor_address: None,
|
||||||
|
node_address: None,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
wifi_ssids: vec![],
|
wifi_ssids: vec![],
|
||||||
zram_enabled: false,
|
zram_enabled: false,
|
||||||
|
|||||||
158
core/archipelago/src/electrs_status.rs
Normal file
158
core/archipelago/src/electrs_status.rs
Normal file
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch electrs indexed height via Electrum protocol (TCP JSON-RPC).
|
||||||
|
fn electrs_indexed_height() -> Result<u64> {
|
||||||
|
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<u64> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
122
core/archipelago/src/identity.rs
Normal file
122
core/archipelago/src/identity.rs
Normal file
@ -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<Self> {
|
||||||
|
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<bool> {
|
||||||
|
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://<onion>#<pubkey>
|
||||||
|
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<String> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
@ -8,8 +8,14 @@ use tracing::info;
|
|||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod electrs_status;
|
||||||
mod container;
|
mod container;
|
||||||
|
mod port_allocator;
|
||||||
mod data_model;
|
mod data_model;
|
||||||
|
mod identity;
|
||||||
|
mod node_message;
|
||||||
|
mod nostr_discovery;
|
||||||
|
mod peers;
|
||||||
mod server;
|
mod server;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
|
|||||||
133
core/archipelago/src/node_message.rs
Normal file
133
core/archipelago/src/node_message.rs
Normal file
@ -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<String>,
|
||||||
|
pub message: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_messages() -> &'static Mutex<Vec<IncomingMessage>> {
|
||||||
|
static RECEIVED: OnceLock<Mutex<Vec<IncomingMessage>>> = 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<IncomingMessage> {
|
||||||
|
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<bool> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
345
core/archipelago/src/nostr_discovery.rs
Normal file
345
core/archipelago/src/nostr_discovery.rs
Normal file
@ -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<SocketAddr> {
|
||||||
|
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<Keys> {
|
||||||
|
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<Option<Keys>> {
|
||||||
|
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<Client> {
|
||||||
|
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<pool::Output<EventId>> {
|
||||||
|
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<String> {
|
||||||
|
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<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_revocation(
|
||||||
|
identity_dir: &Path,
|
||||||
|
tor_proxy: Option<&str>,
|
||||||
|
) -> Result<RevocationStatus> {
|
||||||
|
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<Vec<DiscoveredNode>> {
|
||||||
|
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::<serde_json::Value>(&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)
|
||||||
|
}
|
||||||
63
core/archipelago/src/peers.rs
Normal file
63
core/archipelago/src/peers.rs
Normal file
@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub added_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PeersFile {
|
||||||
|
pub peers: Vec<KnownPeer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PEERS_FILE: &str = "peers.json";
|
||||||
|
|
||||||
|
pub async fn load_peers(data_dir: &Path) -> Result<Vec<KnownPeer>> {
|
||||||
|
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<Vec<KnownPeer>> {
|
||||||
|
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<Vec<KnownPeer>> {
|
||||||
|
let mut peers = load_peers(data_dir).await?;
|
||||||
|
peers.retain(|p| p.pubkey != pubkey);
|
||||||
|
save_peers(data_dir, &peers).await?;
|
||||||
|
Ok(peers)
|
||||||
|
}
|
||||||
148
core/archipelago/src/port_allocator.rs
Normal file
148
core/archipelago/src/port_allocator.rs
Normal file
@ -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<String, PortMapping>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Path>) -> Result<Self> {
|
||||||
|
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<u16> {
|
||||||
|
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<u16> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
use crate::api::ApiHandler;
|
use crate::api::ApiHandler;
|
||||||
use crate::config::{Config, ContainerRuntime};
|
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 crate::state::StateManager;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::server::conn::Http;
|
use hyper::server::conn::Http;
|
||||||
@ -13,6 +15,7 @@ use tracing::{debug, error, info};
|
|||||||
|
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
_config: Config,
|
_config: Config,
|
||||||
|
_identity: Arc<NodeIdentity>,
|
||||||
api_handler: Arc<ApiHandler>,
|
api_handler: Arc<ApiHandler>,
|
||||||
_state_manager: Arc<StateManager>,
|
_state_manager: Arc<StateManager>,
|
||||||
}
|
}
|
||||||
@ -20,17 +23,83 @@ pub struct Server {
|
|||||||
impl Server {
|
impl Server {
|
||||||
pub async fn new(config: Config) -> Result<Self> {
|
pub async fn new(config: Config) -> Result<Self> {
|
||||||
let state_manager = Arc::new(StateManager::new());
|
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?);
|
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
|
// Initialize Docker scanner if in dev mode
|
||||||
if config.dev_mode {
|
if config.dev_mode {
|
||||||
let scanner = create_docker_scanner(&config).await?;
|
let scanner = create_docker_scanner(&config).await?;
|
||||||
let state = state_manager.clone();
|
let state = state_manager.clone();
|
||||||
|
let identity_clone = identity.clone();
|
||||||
|
|
||||||
// Initial scan
|
// Initial scan
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("🐳 Scanning Docker containers...");
|
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);
|
error!("Failed to scan Docker containers: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,7 +107,7 @@ impl Server {
|
|||||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
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);
|
error!("Failed to update Docker containers: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,6 +116,7 @@ impl Server {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
_config: config,
|
_config: config,
|
||||||
|
_identity: identity,
|
||||||
api_handler,
|
api_handler,
|
||||||
_state_manager: state_manager,
|
_state_manager: state_manager,
|
||||||
})
|
})
|
||||||
@ -108,25 +178,42 @@ async fn create_docker_scanner(config: &Config) -> Result<DockerPackageScanner>
|
|||||||
Ok(DockerPackageScanner::new(runtime))
|
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(
|
async fn scan_and_update_packages(
|
||||||
scanner: &DockerPackageScanner,
|
scanner: &DockerPackageScanner,
|
||||||
state: &StateManager,
|
state: &StateManager,
|
||||||
|
identity: &NodeIdentity,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let packages = scanner.scan_containers().await?;
|
let packages = scanner.scan_containers().await?;
|
||||||
|
|
||||||
// Only update if we have packages AND they're different from current state
|
let (current_data, _) = state.get_snapshot().await;
|
||||||
if !packages.is_empty() {
|
let packages_changed = !packages.is_empty() && current_data.package_data != packages;
|
||||||
let (current_data, _) = state.get_snapshot().await;
|
let tor_addr = docker_packages::read_tor_address("archipelago");
|
||||||
|
let tor_changed = tor_addr != current_data.server_info.tor_address;
|
||||||
// Check if packages actually changed to avoid unnecessary broadcasts
|
|
||||||
let packages_changed = current_data.package_data != packages;
|
if packages_changed || tor_changed {
|
||||||
|
let mut data = current_data;
|
||||||
if packages_changed {
|
if !packages.is_empty() {
|
||||||
let mut data = current_data;
|
|
||||||
data.package_data = packages;
|
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(())
|
Ok(())
|
||||||
|
|||||||
@ -97,18 +97,27 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- archy-net
|
- archy-net
|
||||||
|
|
||||||
# Fedimint (using guardians setup)
|
# Fedimint (v0.10+ with built-in Guardian UI)
|
||||||
fedimint:
|
fedimint:
|
||||||
image: fedimint/fedimintd:v0.3.0
|
image: fedimint/fedimintd:v0.10.0
|
||||||
container_name: archy-fedimint
|
container_name: archy-fedimint
|
||||||
platform: linux/amd64 # Emulate x86 on ARM Macs
|
platform: linux/amd64 # Emulate x86 on ARM Macs
|
||||||
ports:
|
ports:
|
||||||
- "8173:8173"
|
- "8173:8173" # P2P
|
||||||
|
- "8174:8174" # API (JSON-RPC)
|
||||||
|
- "8175:8175" # Built-in Guardian UI
|
||||||
volumes:
|
volumes:
|
||||||
- fedimint-data:/data
|
- fedimint-data:/data
|
||||||
environment:
|
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_P2P: 0.0.0.0:8173
|
||||||
FM_BIND_API: 0.0.0.0:8174
|
FM_BIND_API: 0.0.0.0:8174
|
||||||
|
FM_BIND_UI: 0.0.0.0:8175
|
||||||
|
depends_on:
|
||||||
|
- bitcoin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- archy-net
|
- archy-net
|
||||||
|
|||||||
10
docker/electrs-ui/Dockerfile
Normal file
10
docker/electrs-ui/Dockerfile
Normal file
@ -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;"]
|
||||||
145
docker/electrs-ui/index.html
Normal file
145
docker/electrs-ui/index.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<title>Electrs - Archipelago</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
|
||||||
|
.bg-layer { position: fixed; inset: 0; z-index: -10; background: linear-gradient(135deg, rgba(0,0,0,0.9) 0%, rgba(30,30,50,0.95) 100%); background-size: cover; background-position: center; }
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); z-index: -5; }
|
||||||
|
.glass-card { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.18); }
|
||||||
|
.info-card { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(24px); border-radius: 16px; padding: 12px; border: 1px solid rgba(255, 255, 255, 0.1); }
|
||||||
|
@keyframes progressGlow { 0%, 100% { box-shadow: 0 0 10px rgba(251, 146, 60, 0.5); } 50% { box-shadow: 0 0 20px rgba(251, 146, 60, 0.8); } }
|
||||||
|
.progress-glow { animation: progressGlow 2s ease-in-out infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.animate-spin-slow { animation: spin 3s linear infinite; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-layer"></div>
|
||||||
|
<div class="overlay"></div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto p-8">
|
||||||
|
<div class="glass-card p-6 mb-6">
|
||||||
|
<div class="flex flex-col md:flex-row items-center gap-4">
|
||||||
|
<div class="flex-shrink-0 w-16 h-16 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Electrs</h1>
|
||||||
|
<p class="text-white/70">Bitcoin Electrum indexer for Mempool & Electrum clients</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card flex items-center gap-3">
|
||||||
|
<div id="statusDot" class="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/60">Status</p>
|
||||||
|
<p class="text-sm font-medium text-white" id="statusText">Checking...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex items-start gap-4 mb-4">
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<svg id="syncIcon" class="w-6 h-6 text-orange-500 animate-spin-slow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-1">Index Sync</h2>
|
||||||
|
<p class="text-white/70 text-sm" id="syncStatusText">Checking sync status...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex justify-between text-sm text-white/60 mb-2">
|
||||||
|
<span id="currentBlock">Block 0</span>
|
||||||
|
<span id="syncPercentage">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-white/10 rounded-full h-3 overflow-hidden">
|
||||||
|
<div class="h-full bg-gradient-to-r from-orange-500 to-yellow-400 rounded-full transition-all duration-500 progress-glow" id="syncProgressBar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div class="info-card">
|
||||||
|
<p class="text-xs text-white/60 mb-1">Indexed Height</p>
|
||||||
|
<p class="text-lg font-semibold text-white" id="indexedHeight">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<p class="text-xs text-white/60 mb-1">Network Height</p>
|
||||||
|
<p class="text-lg font-semibold text-white" id="networkHeight">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<p class="text-xs text-white/60 mb-1">Electrum RPC</p>
|
||||||
|
<p class="text-sm font-mono text-white/90">localhost:50001</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<p class="text-xs text-white/60 mb-1">Progress</p>
|
||||||
|
<p class="text-lg font-semibold text-white" id="progressPct">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function updateStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/electrs-status');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('indexedHeight').textContent = data.indexed_height?.toLocaleString() ?? '-';
|
||||||
|
document.getElementById('networkHeight').textContent = data.network_height?.toLocaleString() ?? '-';
|
||||||
|
document.getElementById('progressPct').textContent = data.progress_pct != null ? data.progress_pct.toFixed(2) + '%' : '-';
|
||||||
|
document.getElementById('currentBlock').textContent = 'Block ' + (data.indexed_height?.toLocaleString() ?? '0');
|
||||||
|
document.getElementById('syncPercentage').textContent = (data.progress_pct ?? 0).toFixed(2) + '%';
|
||||||
|
document.getElementById('syncProgressBar').style.width = (data.progress_pct ?? 0) + '%';
|
||||||
|
|
||||||
|
const statusText = document.getElementById('syncStatusText');
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const syncIcon = document.getElementById('syncIcon');
|
||||||
|
|
||||||
|
if (data.status === 'indexing') {
|
||||||
|
statusText.textContent = data.error || 'Building index... Electrum RPC will be available when indexing completes (may take hours).';
|
||||||
|
statusText.className = 'text-amber-400 text-sm';
|
||||||
|
statusDot.className = 'w-3 h-3 rounded-full bg-amber-400 animate-pulse';
|
||||||
|
document.getElementById('statusText').textContent = 'Indexing';
|
||||||
|
syncIcon.classList.add('animate-spin-slow');
|
||||||
|
} else if (data.error) {
|
||||||
|
statusText.textContent = data.error;
|
||||||
|
statusText.className = 'text-red-400 text-sm';
|
||||||
|
statusDot.className = 'w-3 h-3 rounded-full bg-red-400';
|
||||||
|
document.getElementById('statusText').textContent = 'Error';
|
||||||
|
} else if (data.status === 'synced') {
|
||||||
|
statusText.textContent = '✓ Fully synchronized with the network';
|
||||||
|
statusText.className = 'text-green-400 text-sm font-medium';
|
||||||
|
statusDot.className = 'w-3 h-3 rounded-full bg-green-400';
|
||||||
|
document.getElementById('statusText').textContent = 'Synced';
|
||||||
|
syncIcon.classList.remove('animate-spin-slow');
|
||||||
|
syncIcon.classList.add('text-green-500');
|
||||||
|
} else {
|
||||||
|
const remaining = (data.network_height || 0) - (data.indexed_height || 0);
|
||||||
|
statusText.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
|
||||||
|
statusText.className = 'text-orange-400 text-sm font-medium';
|
||||||
|
statusDot.className = 'w-3 h-3 rounded-full bg-yellow-400';
|
||||||
|
document.getElementById('statusText').textContent = 'Syncing';
|
||||||
|
syncIcon.classList.add('animate-spin-slow');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('syncStatusText').textContent = 'Unable to fetch status: ' + e.message;
|
||||||
|
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus();
|
||||||
|
setInterval(updateStatus, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
docker/electrs-ui/nginx.conf
Normal file
19
docker/electrs-ui/nginx.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 8081;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
42
docs/SECURITY-NOSTR-DISCOVERY.md
Normal file
42
docs/SECURITY-NOSTR-DISCOVERY.md
Normal file
@ -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.
|
||||||
68
docs/WEB5_NOSTR_IDENTITY.md
Normal file
68
docs/WEB5_NOSTR_IDENTITY.md
Normal file
@ -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<base58btc(multicodec_ed25519_pub + 32-byte pubkey)>`
|
||||||
|
- **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://<onion>#<pubkey>` 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": "<nostr_secp256k1_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
|
||||||
@ -118,6 +118,7 @@ main_menu() {
|
|||||||
echo " Main Menu:"
|
echo " Main Menu:"
|
||||||
echo " ─────────────────────────────────────────────────────────────"
|
echo " ─────────────────────────────────────────────────────────────"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " r) Refresh - Update IP/status (no restart needed)"
|
||||||
echo " w) Open Web UI - Launch graphical interface"
|
echo " w) Open Web UI - Launch graphical interface"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1) Install to Disk - Permanently install Archipelago"
|
echo " 1) Install to Disk - Permanently install Archipelago"
|
||||||
@ -133,6 +134,9 @@ main_menu() {
|
|||||||
read -p " Select option: " choice
|
read -p " Select option: " choice
|
||||||
|
|
||||||
case $choice in
|
case $choice in
|
||||||
|
r|R)
|
||||||
|
# Refresh - just loop again to show updated IP/status
|
||||||
|
;;
|
||||||
w|W)
|
w|W)
|
||||||
echo ""
|
echo ""
|
||||||
# Start the real backend on port 5678
|
# Start the real backend on port 5678
|
||||||
|
|||||||
@ -68,6 +68,9 @@ check_tools() {
|
|||||||
if ! command -v xorriso >/dev/null 2>&1; then
|
if ! command -v xorriso >/dev/null 2>&1; then
|
||||||
missing="$missing xorriso"
|
missing="$missing xorriso"
|
||||||
fi
|
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
|
if [ -n "$missing" ]; then
|
||||||
echo "❌ Missing required tools:$missing"
|
echo "❌ Missing required tools:$missing"
|
||||||
@ -79,6 +82,9 @@ check_tools() {
|
|||||||
if [[ "$missing" == *"xorriso"* ]]; then
|
if [[ "$missing" == *"xorriso"* ]]; then
|
||||||
apt-get install -y xorriso
|
apt-get install -y xorriso
|
||||||
fi
|
fi
|
||||||
|
if [[ "$missing" == *"p7zip-full"* ]]; then
|
||||||
|
apt-get install -y p7zip-full
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$missing" == *"docker-or-podman"* ]]; then
|
if [[ "$missing" == *"docker-or-podman"* ]]; then
|
||||||
echo " Installing podman..."
|
echo " Installing podman..."
|
||||||
@ -208,12 +214,24 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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
|
# Proxy API requests to backend
|
||||||
location /rpc/ {
|
location /rpc/ {
|
||||||
proxy_pass http://127.0.0.1:5678;
|
proxy_pass http://127.0.0.1:5678;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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
|
# Proxy WebSocket
|
||||||
@ -223,6 +241,7 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NGINXCONF
|
NGINXCONF
|
||||||
@ -268,7 +287,7 @@ echo "📦 Step 2: Creating installer environment..."
|
|||||||
|
|
||||||
# Download Debian Live as our installer base
|
# Download Debian Live as our installer base
|
||||||
BASE_ISO="$WORK_DIR/debian-live-installer.iso"
|
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
|
# Check if file exists and is complete
|
||||||
if [ -f "$BASE_ISO" ]; then
|
if [ -f "$BASE_ISO" ]; then
|
||||||
@ -287,7 +306,7 @@ if [ ! -f "$BASE_ISO" ]; then
|
|||||||
|
|
||||||
# Use wget without -O so --continue actually works
|
# Use wget without -O so --continue actually works
|
||||||
# Download with the ugly SourceForge filename, then rename
|
# 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
|
if command -v wget >/dev/null 2>&1; then
|
||||||
cd "$WORK_DIR"
|
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)
|
# Find the downloaded file (wget creates it with a name like "download" or the actual filename)
|
||||||
if [ -f "download" ]; then
|
if [ -f "download" ]; then
|
||||||
mv "download" "$BASE_ISO"
|
mv "download" "$BASE_ISO"
|
||||||
elif [ -f "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" ]; then
|
elif [ -f "debian-live-13.3.0-amd64-standard.iso" ]; then
|
||||||
mv "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" "$BASE_ISO"
|
mv "debian-live-13.3.0-amd64-standard.iso" "$BASE_ISO"
|
||||||
else
|
else
|
||||||
echo " ❌ Downloaded file not found"
|
echo " ❌ Downloaded file not found"
|
||||||
exit 1
|
exit 1
|
||||||
@ -335,7 +354,10 @@ INSTALLER_ISO="$WORK_DIR/installer-iso"
|
|||||||
rm -rf "$INSTALLER_ISO"
|
rm -rf "$INSTALLER_ISO"
|
||||||
mkdir -p "$INSTALLER_ISO"
|
mkdir -p "$INSTALLER_ISO"
|
||||||
cd "$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
|
# STEP 3: Add Archipelago components
|
||||||
@ -362,17 +384,15 @@ fi
|
|||||||
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
||||||
BACKEND_CAPTURED=0
|
BACKEND_CAPTURED=0
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
# Check if we're running on the server itself (localhost or same machine)
|
# Direct copy from local filesystem (when running on target with sudo)
|
||||||
if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then
|
if [ -f "/usr/local/bin/archipelago" ]; then
|
||||||
# Direct copy from local filesystem
|
cp "/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago"
|
||||||
if [ -f "/usr/local/bin/archipelago" ]; then
|
chmod +x "$ARCH_DIR/bin/archipelago"
|
||||||
cp "/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago"
|
echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
||||||
chmod +x "$ARCH_DIR/bin/archipelago"
|
BACKEND_CAPTURED=1
|
||||||
echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
fi
|
||||||
BACKEND_CAPTURED=1
|
# Remote copy via SCP if local failed
|
||||||
fi
|
if [ "$BACKEND_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||||
else
|
|
||||||
# Remote copy via SCP
|
|
||||||
if scp "$DEV_SERVER:/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" 2>/dev/null; then
|
if scp "$DEV_SERVER:/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" 2>/dev/null; then
|
||||||
chmod +x "$ARCH_DIR/bin/archipelago"
|
chmod +x "$ARCH_DIR/bin/archipelago"
|
||||||
echo " ✅ Backend captured from remote server ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
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)
|
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
||||||
WEBUI_CAPTURED=0
|
WEBUI_CAPTURED=0
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
# Check if we're running on the server itself
|
# Direct copy from local filesystem (when running on target with sudo)
|
||||||
if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then
|
if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then
|
||||||
# Direct copy from local filesystem
|
cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/"
|
||||||
if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then
|
echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
||||||
cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/"
|
WEBUI_CAPTURED=1
|
||||||
echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
fi
|
||||||
WEBUI_CAPTURED=1
|
# Remote copy via rsync if local failed
|
||||||
fi
|
if [ "$WEBUI_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||||
else
|
|
||||||
# Remote copy via rsync
|
|
||||||
if rsync -az "$DEV_SERVER:/opt/archipelago/web-ui/" "$ARCH_DIR/web-ui/" 2>/dev/null && [ "$(ls -A "$ARCH_DIR/web-ui")" ]; 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))"
|
echo " ✅ Web UI captured from remote server ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
||||||
WEBUI_CAPTURED=1
|
WEBUI_CAPTURED=1
|
||||||
@ -481,7 +499,7 @@ mkdir -p "$IMAGES_DIR"
|
|||||||
IMAGES_CAPTURED_FROM_SERVER=0
|
IMAGES_CAPTURED_FROM_SERVER=0
|
||||||
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||||
echo " Capturing container images from live server ($DEV_SERVER)..."
|
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-$$"
|
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
|
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
|
for p in $SAVED_LIST; do
|
||||||
@ -503,11 +521,16 @@ bitcoinknots/bitcoin:29 bitcoin-knots.tar
|
|||||||
lightninglabs/lnd:v0.18.4-beta lnd.tar
|
lightninglabs/lnd:v0.18.4-beta lnd.tar
|
||||||
ghcr.io/home-assistant/home-assistant:stable homeassistant.tar
|
ghcr.io/home-assistant/home-assistant:stable homeassistant.tar
|
||||||
btcpayserver/btcpayserver:latest btcpayserver.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
|
docker.io/filebrowser/filebrowser:latest filebrowser.tar
|
||||||
scsibug/nostr-rs-relay:latest nostr-rs-relay.tar
|
scsibug/nostr-rs-relay:latest nostr-rs-relay.tar
|
||||||
hoytech/strfry:latest strfry.tar
|
hoytech/strfry:latest strfry.tar
|
||||||
tailscale/tailscale:latest tailscale.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
|
# 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
|
fi
|
||||||
done
|
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): Container image load complete" >> "$LOG_FILE"
|
||||||
echo "$(date): Available images:" >> "$LOG_FILE"
|
echo "$(date): Available images:" >> "$LOG_FILE"
|
||||||
podman images >> "$LOG_FILE" 2>&1
|
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/load-container-images.sh" "$ARCH_DIR/scripts/"
|
||||||
cp "$WORK_DIR/archipelago-load-images.service" "$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
|
# 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
|
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/
|
cp "$BOOT_MEDIA/archipelago/scripts/archipelago-load-images.service" /mnt/target/etc/systemd/system/
|
||||||
fi
|
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"
|
echo " ✅ Container images staged for first-boot loading"
|
||||||
fi
|
fi
|
||||||
@ -873,7 +988,7 @@ chmod +x /mnt/target/etc/profile.d/archipelago.sh
|
|||||||
cat > /mnt/target/etc/systemd/system/archipelago.service <<'SERVICE'
|
cat > /mnt/target/etc/systemd/system/archipelago.service <<'SERVICE'
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Archipelago Backend
|
Description=Archipelago Backend
|
||||||
After=network-online.target
|
After=network-online.target archipelago-setup-tor.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[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 archipelago.service 2>/dev/null || true
|
||||||
chroot /mnt/target systemctl enable nginx.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-load-images.service 2>/dev/null || true
|
||||||
|
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
sync
|
sync
|
||||||
|
|||||||
@ -8,6 +8,9 @@ Type=simple
|
|||||||
User=root
|
User=root
|
||||||
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
||||||
Environment="ARCHIPELAGO_DEV_MODE=true"
|
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
|
ExecStart=/usr/local/bin/archipelago
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@ -11,6 +11,14 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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
|
# Proxy API requests to backend
|
||||||
location /rpc/ {
|
location /rpc/ {
|
||||||
proxy_pass http://127.0.0.1:5678;
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.su3n1rkrf7k"
|
"revision": "0.8432ene9gn8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@ -140,6 +140,7 @@ async function getDockerContainers() {
|
|||||||
'archy-morphos': 'morphos-server',
|
'archy-morphos': 'morphos-server',
|
||||||
'archy-lnd': 'lightning-stack',
|
'archy-lnd': 'lightning-stack',
|
||||||
'archy-mempool-web': 'mempool',
|
'archy-mempool-web': 'mempool',
|
||||||
|
'mempool-electrs': 'mempool-electrs',
|
||||||
'archy-ollama': 'ollama',
|
'archy-ollama': 'ollama',
|
||||||
'archy-searxng': 'searxng',
|
'archy-searxng': 'searxng',
|
||||||
'archy-onlyoffice': 'onlyoffice',
|
'archy-onlyoffice': 'onlyoffice',
|
||||||
@ -187,7 +188,7 @@ async function getDockerContainers() {
|
|||||||
},
|
},
|
||||||
'fedimint': {
|
'fedimint': {
|
||||||
title: 'Fedimint',
|
title: 'Fedimint',
|
||||||
icon: '/assets/img/icon-fedimint.jpeg',
|
icon: '/assets/img/app-icons/fedimint.png',
|
||||||
description: 'Federated Bitcoin mint'
|
description: 'Federated Bitcoin mint'
|
||||||
},
|
},
|
||||||
'morphos-server': {
|
'morphos-server': {
|
||||||
@ -205,6 +206,11 @@ async function getDockerContainers() {
|
|||||||
icon: '/assets/img/app-icons/mempool.png',
|
icon: '/assets/img/app-icons/mempool.png',
|
||||||
description: 'Bitcoin blockchain explorer'
|
description: 'Bitcoin blockchain explorer'
|
||||||
},
|
},
|
||||||
|
'mempool-electrs': {
|
||||||
|
title: 'Electrs',
|
||||||
|
icon: '/assets/img/app-icons/electrs.svg',
|
||||||
|
description: 'Electrum protocol indexer for Bitcoin'
|
||||||
|
},
|
||||||
'ollama': {
|
'ollama': {
|
||||||
title: 'Ollama',
|
title: 'Ollama',
|
||||||
icon: '/assets/img/app-icons/ollama.png',
|
icon: '/assets/img/app-icons/ollama.png',
|
||||||
@ -623,13 +629,25 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
case 'auth.onboardingComplete': {
|
case 'auth.onboardingComplete': {
|
||||||
userState.onboardingComplete = true
|
userState.onboardingComplete = true
|
||||||
console.log(`[Auth] Onboarding completed`)
|
console.log(`[Auth] Onboarding completed`)
|
||||||
return res.json({ result: { success: true } })
|
return res.json({ result: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'auth.isOnboardingComplete': {
|
case 'auth.isOnboardingComplete': {
|
||||||
return res.json({ result: userState.onboardingComplete })
|
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': {
|
case 'auth.login': {
|
||||||
const { password } = params
|
const { password } = params
|
||||||
|
|
||||||
|
|||||||
10
neode-ui/package-lock.json
generated
10
neode-ui/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^4.0.9",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
@ -4878,6 +4879,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^4.0.9",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
|
|||||||
11
neode-ui/public/assets/img/app-icons/README.md
Normal file
11
neode-ui/public/assets/img/app-icons/README.md
Normal file
@ -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.
|
||||||
12
neode-ui/public/assets/img/app-icons/electrs.svg
Normal file
12
neode-ui/public/assets/img/app-icons/electrs.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
@ -42,6 +42,13 @@ const repoMap = {
|
|||||||
'penpot': 'penpot-startos',
|
'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')
|
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@ -82,36 +89,52 @@ function downloadFile(url, filepath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadIcon(appId) {
|
async function downloadIcon(appId) {
|
||||||
const repoName = repoMap[appId] || `${appId}-startos`
|
const targetExt = 'webp' // Prefer webp for consistency with mempool, etc.
|
||||||
|
const fallbackExts = ['webp', 'png', 'svg']
|
||||||
// Try multiple icon paths
|
const filepath = path.join(iconDir, `${appId}.webp`)
|
||||||
const iconPaths = [
|
|
||||||
`icon.png`,
|
// Skip if file already exists
|
||||||
`icon.svg`,
|
if (appId === 'fedimint' && fs.existsSync(path.join(iconDir, 'fedimint.png'))) {
|
||||||
`assets/icon.png`,
|
console.log(`⏭️ Skipping ${appId} (fedimint.png exists)`)
|
||||||
`assets/icon.svg`,
|
return true
|
||||||
]
|
}
|
||||||
|
for (const ext of fallbackExts) {
|
||||||
for (const iconPath of iconPaths) {
|
const fp = path.join(iconDir, `${appId}.${ext}`)
|
||||||
const url = `https://raw.githubusercontent.com/Start9Labs/${repoName}/main/${iconPath}`
|
if (fs.existsSync(fp)) {
|
||||||
const extension = iconPath.endsWith('.svg') ? 'svg' : 'png'
|
|
||||||
const filepath = path.join(iconDir, `${appId}.${extension}`)
|
|
||||||
|
|
||||||
// Skip if file already exists
|
|
||||||
if (fs.existsSync(filepath)) {
|
|
||||||
console.log(`⏭️ Skipping ${appId} (already exists)`)
|
console.log(`⏭️ Skipping ${appId} (already exists)`)
|
||||||
return true
|
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 {
|
try {
|
||||||
await downloadFile(url, filepath)
|
await downloadFile(url, fp)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Try next path
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`❌ Failed to download icon for ${appId}`)
|
console.log(`❌ Failed to download icon for ${appId}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,46 @@
|
|||||||
<!-- Main App Content - only show after splash and routing is complete -->
|
<!-- Main App Content - only show after splash and routing is complete -->
|
||||||
<RouterView v-if="!showSplash && isReady" />
|
<RouterView v-if="!showSplash && isReady" />
|
||||||
|
|
||||||
|
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||||
|
<SpotlightSearch />
|
||||||
|
|
||||||
|
<!-- Help guide modal (from spotlight) -->
|
||||||
|
<HelpGuideModal
|
||||||
|
:show="spotlightStore.helpModal.show"
|
||||||
|
:title="spotlightStore.helpModal.title"
|
||||||
|
:content="spotlightStore.helpModal.content"
|
||||||
|
:related-path="spotlightStore.helpModal.relatedPath"
|
||||||
|
@close="spotlightStore.closeHelpModal()"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- PWA Update Prompt -->
|
<!-- PWA Update Prompt -->
|
||||||
<PWAUpdatePrompt />
|
<PWAUpdatePrompt />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import SplashScreen from './components/SplashScreen.vue'
|
import SplashScreen from './components/SplashScreen.vue'
|
||||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||||
|
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||||
|
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||||
|
import { useControllerNav } from '@/composables/useControllerNav'
|
||||||
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const spotlightStore = useSpotlightStore()
|
||||||
|
useControllerNav()
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||||
|
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||||
|
if (mod && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
spotlightStore.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showSplash = ref(true)
|
const showSplash = ref(true)
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
@ -29,6 +57,7 @@ const isReady = ref(false)
|
|||||||
* - User is on a direct route (refresh/bookmark)
|
* - User is on a direct route (refresh/bookmark)
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const isDirectRoute = route.path !== '/'
|
const isDirectRoute = route.path !== '/'
|
||||||
|
|
||||||
@ -43,48 +72,33 @@ onMounted(() => {
|
|||||||
// SplashScreen will emit 'complete' which calls handleSplashComplete
|
// SplashScreen will emit 'complete' which calls handleSplashComplete
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle splash screen completion
|
* Handle splash screen completion
|
||||||
* Routes user directly to appropriate screen based on onboarding status
|
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||||
*/
|
*/
|
||||||
function handleSplashComplete() {
|
async function handleSplashComplete() {
|
||||||
showSplash.value = false
|
showSplash.value = false
|
||||||
document.body.classList.add('splash-complete')
|
document.body.classList.add('splash-complete')
|
||||||
|
|
||||||
// Set isReady first so RouterView can render
|
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
|
|
||||||
// Determine destination based on onboarding status and dev mode
|
|
||||||
const devMode = import.meta.env.VITE_DEV_MODE
|
const devMode = import.meta.env.VITE_DEV_MODE
|
||||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
if (devMode === 'setup' || devMode === 'existing') {
|
||||||
// const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
router.push('/login').catch(() => {})
|
||||||
|
return
|
||||||
let destination = '/'
|
|
||||||
|
|
||||||
// Setup mode: always go to login
|
|
||||||
if (devMode === 'setup') {
|
|
||||||
destination = '/login'
|
|
||||||
}
|
}
|
||||||
// Onboarding mode: go to onboarding if not seen
|
|
||||||
else if (devMode === 'onboarding') {
|
try {
|
||||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
const { isOnboardingComplete } = await import('@/composables/useOnboarding')
|
||||||
|
const seenOnboarding = await isOnboardingComplete()
|
||||||
|
const destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||||
|
router.push(destination).catch(() => {})
|
||||||
|
} catch {
|
||||||
|
router.push('/onboarding/intro').catch(() => {})
|
||||||
}
|
}
|
||||||
// Existing user mode: go to login
|
|
||||||
else if (devMode === 'existing') {
|
|
||||||
destination = '/login'
|
|
||||||
}
|
|
||||||
// Default: check onboarding status
|
|
||||||
else {
|
|
||||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route after a brief delay to ensure RouterView is mounted
|
|
||||||
// The router's redirect will handle the actual navigation
|
|
||||||
router.push(destination).catch(err => {
|
|
||||||
console.error('Navigation error:', err)
|
|
||||||
// Still show the app even if navigation fails
|
|
||||||
isReady.value = true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -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<void> {
|
async logout(): Promise<void> {
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'auth.logout',
|
method: 'auth.logout',
|
||||||
@ -84,6 +99,113 @@ class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeOnboarding(): Promise<boolean> {
|
||||||
|
return this.call({
|
||||||
|
method: 'auth.onboardingComplete',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnboardingComplete(): Promise<boolean> {
|
||||||
|
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<string> {
|
async echo(message: string): Promise<string> {
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'server.echo',
|
method: 'server.echo',
|
||||||
|
|||||||
109
neode-ui/src/components/AnimatedLogo.vue
Normal file
109
neode-ui/src/components/AnimatedLogo.vue
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="logo-gradient-border flex-shrink-0 inline-block overflow-hidden">
|
||||||
|
<!-- Neode logo - white or coloured -->
|
||||||
|
<svg
|
||||||
|
class="w-14 h-14 block logo-svg"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Neode"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f9aa4b" />
|
||||||
|
<stop offset="50%" stop-color="#f7931a" />
|
||||||
|
<stop offset="100%" stop-color="#e68a19" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" fill="#030202" />
|
||||||
|
<rect
|
||||||
|
v-for="(r, i) in rects"
|
||||||
|
:key="i"
|
||||||
|
:x="r.x"
|
||||||
|
:y="r.y"
|
||||||
|
:width="r.w"
|
||||||
|
:height="r.h"
|
||||||
|
:fill="mode === 'coloured' ? `url(#${gradientId})` : 'white'"
|
||||||
|
class="logo-square"
|
||||||
|
:class="{ 'logo-square-coloured': mode === 'coloured' }"
|
||||||
|
:style="{ '--delay': delays[i] + 'ms' }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
const gradientId = 'logo-color-' + Math.random().toString(36).slice(2, 11)
|
||||||
|
|
||||||
|
// Parsed from favico-black.svg path - 20 rects
|
||||||
|
const rects = [
|
||||||
|
{ x: 357.614, y: 318, w: 71.007, h: 70.936 },
|
||||||
|
{ x: 436.152, y: 318, w: 72.082, h: 70.936 },
|
||||||
|
{ x: 515.766, y: 318, w: 72.082, h: 70.936 },
|
||||||
|
{ x: 595.379, y: 318, w: 71.007, h: 70.936 },
|
||||||
|
{ x: 595.379, y: 396.46, w: 71.007, h: 72.011 },
|
||||||
|
{ x: 673.917, y: 396.46, w: 72.083, h: 72.011 },
|
||||||
|
{ x: 278, y: 475.994, w: 72.083, h: 72.012 },
|
||||||
|
{ x: 357.614, y: 475.994, w: 71.007, h: 72.012 },
|
||||||
|
{ x: 436.152, y: 475.994, w: 72.082, h: 72.012 },
|
||||||
|
{ x: 515.766, y: 475.994, w: 72.082, h: 72.012 },
|
||||||
|
{ x: 595.379, y: 475.994, w: 71.007, h: 72.012 },
|
||||||
|
{ x: 673.917, y: 475.994, w: 72.083, h: 72.012 },
|
||||||
|
{ x: 278, y: 555.529, w: 72.083, h: 70.936 },
|
||||||
|
{ x: 357.614, y: 555.529, w: 71.007, h: 70.936 },
|
||||||
|
{ x: 595.379, y: 555.529, w: 71.007, h: 70.936 },
|
||||||
|
{ x: 673.917, y: 555.529, w: 72.083, h: 70.936 },
|
||||||
|
{ x: 357.614, y: 633.989, w: 71.007, h: 72.011 },
|
||||||
|
{ x: 436.152, y: 633.989, w: 72.082, h: 72.011 },
|
||||||
|
{ x: 515.766, y: 633.989, w: 72.082, h: 72.011 },
|
||||||
|
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Stagger delays (ms) - spread over ~4.5s load phase
|
||||||
|
const delays = [0, 2341, 467, 1890, 312, 3456, 123, 2789, 567, 4123, 901, 1456, 234, 3789, 2678, 456, 847, 2912, 1891, 423]
|
||||||
|
|
||||||
|
type Mode = 'normal' | 'coloured'
|
||||||
|
const mode = ref<Mode>('normal')
|
||||||
|
let cycleCount = 0
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const CYCLE_MS = 10000
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
cycleCount++
|
||||||
|
mode.value = cycleCount % 4 === 3 ? 'coloured' : 'normal'
|
||||||
|
}, CYCLE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo-svg {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-square {
|
||||||
|
opacity: 0;
|
||||||
|
animation: logo-square-in 10s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
animation-delay: var(--delay, 0ms);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-square-coloured {
|
||||||
|
filter: drop-shadow(0 0 2px rgba(247, 147, 26, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 0–45%: squares load in. 45–100%: full logo visible */
|
||||||
|
@keyframes logo-square-in {
|
||||||
|
0% { opacity: 0; transform: scale(0.95); }
|
||||||
|
4% { opacity: 1; transform: scale(1); }
|
||||||
|
45% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
neode-ui/src/components/ControllerIndicator.vue
Normal file
21
neode-ui/src/components/ControllerIndicator.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="store.isActive"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10"
|
||||||
|
title="Controller connected - use arrows & Enter to navigate"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 text-amber-400/90 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="4" y="8" width="16" height="10" rx="2" stroke-width="2" />
|
||||||
|
<circle cx="9" cy="13" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
|
||||||
|
<path stroke-linecap="round" stroke-width="2" d="M12 10v2M11 11h2" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs text-white/70 hidden sm:inline">Controller</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useControllerStore } from '@/stores/controller'
|
||||||
|
|
||||||
|
const store = useControllerStore()
|
||||||
|
</script>
|
||||||
58
neode-ui/src/components/HelpGuideModal.vue
Normal file
58
neode-ui/src/components/HelpGuideModal.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
|
<div
|
||||||
|
@click.stop
|
||||||
|
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-white/80 prose prose-invert max-w-none">
|
||||||
|
<p class="whitespace-pre-wrap">{{ content }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="relatedPath" class="mt-4">
|
||||||
|
<router-link
|
||||||
|
:to="relatedPath"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
Go to related page
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
relatedPath?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
361
neode-ui/src/components/SpotlightSearch.vue
Normal file
361
neode-ui/src/components/SpotlightSearch.vue
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="spotlight">
|
||||||
|
<div
|
||||||
|
v-if="spotlightStore.isOpen"
|
||||||
|
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
|
||||||
|
@click.self="spotlightStore.close()"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
|
||||||
|
:style="panelStyle"
|
||||||
|
@mousedown="onPanelMouseDown"
|
||||||
|
>
|
||||||
|
<!-- Header: drag handle grip + search -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
|
||||||
|
<div
|
||||||
|
ref="dragHandleRef"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
|
||||||
|
:class="{ 'cursor-grabbing': isDragging }"
|
||||||
|
title="Drag to move"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search or type a command..."
|
||||||
|
class="flex-1 bg-transparent text-white placeholder-white/50 outline-none text-base"
|
||||||
|
@keydown="onInputKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto max-h-[60vh] min-h-[200px]">
|
||||||
|
<!-- Recent items (when no query and we have recent) -->
|
||||||
|
<div v-if="!query.trim() && spotlightStore.recentItems.length > 0" class="p-2 border-b border-white/10">
|
||||||
|
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">Recent</div>
|
||||||
|
<button
|
||||||
|
v-for="(item, idx) in spotlightStore.recentItems"
|
||||||
|
:key="`recent-${item.id}-${item.timestamp}`"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||||
|
:class="getItemClass(idx)"
|
||||||
|
@click="selectRecent(item)"
|
||||||
|
>
|
||||||
|
<span class="text-white/90">{{ item.label }}</span>
|
||||||
|
<span class="text-xs text-white/40">{{ item.type }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search results or help tree -->
|
||||||
|
<template v-if="query.trim()">
|
||||||
|
<div v-if="filteredItems.length > 0" class="p-2">
|
||||||
|
<button
|
||||||
|
v-for="(item, idx) in filteredItems"
|
||||||
|
:key="item.id + item.section"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||||
|
:class="getItemClass(idx)"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
>
|
||||||
|
<span class="text-white/90">{{ item.label }}</span>
|
||||||
|
<span class="text-xs text-white/40">{{ item.section }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-8 text-center text-white/50">
|
||||||
|
No results for "{{ query }}"
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Help tree when no search -->
|
||||||
|
<div v-for="section in helpTree" :key="section.id" class="p-2">
|
||||||
|
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">{{ section.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="(item, idx) in section.items"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||||
|
:class="getItemClass(recentOffset + getFlatIndex(section.id, idx))"
|
||||||
|
@click="selectHelpItem(section, item)"
|
||||||
|
>
|
||||||
|
<span class="text-white/90">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- AI Assistant placeholder -->
|
||||||
|
<div class="p-2 border-t border-white/10">
|
||||||
|
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
|
||||||
|
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
|
||||||
|
Coming soon — ask questions about your node, apps, and Bitcoin.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
|
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const spotlightStore = useSpotlightStore()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
|
||||||
|
|
||||||
|
const searchableItems = flattenForSearch()
|
||||||
|
const fuse = new Fuse(searchableItems, {
|
||||||
|
keys: ['label', 'section'],
|
||||||
|
threshold: 0.4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q) return []
|
||||||
|
const results = fuse.search(q)
|
||||||
|
return results.map((r) => r.item)
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentOffset = computed(() =>
|
||||||
|
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectableCount = computed(() => {
|
||||||
|
if (query.value.trim()) return filteredItems.value.length
|
||||||
|
return recentOffset.value + searchableItems.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelStyle = computed(() => {
|
||||||
|
const pos = savedPosition.value
|
||||||
|
if (!pos) return {}
|
||||||
|
return {
|
||||||
|
transform: `translate(${pos.x}px, ${pos.y}px)`,
|
||||||
|
margin: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SAVED_POSITION_KEY = 'archipelago-spotlight-position'
|
||||||
|
const savedPosition = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
function loadSavedPosition() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVED_POSITION_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
|
||||||
|
} else {
|
||||||
|
savedPosition.value = null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
savedPosition.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePosition(x: number, y: number) {
|
||||||
|
savedPosition.value = { x, y }
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlatIndex(sectionId: string, itemIdx: number): number {
|
||||||
|
let idx = 0
|
||||||
|
for (const s of helpTree) {
|
||||||
|
if (s.id === sectionId) return idx + itemIdx
|
||||||
|
idx += s.items.length
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemClass(index: number) {
|
||||||
|
const selected = spotlightStore.selectedIndex
|
||||||
|
return index === selected
|
||||||
|
? 'bg-amber-500/20 text-amber-200'
|
||||||
|
: 'hover:bg-white/10 text-white/90'
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: SearchableItem) {
|
||||||
|
spotlightStore.addRecentItem({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
path: item.path,
|
||||||
|
type: item.type,
|
||||||
|
})
|
||||||
|
spotlightStore.close()
|
||||||
|
if (item.path) {
|
||||||
|
router.push(item.path)
|
||||||
|
} else if (item.content) {
|
||||||
|
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
|
||||||
|
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
|
||||||
|
spotlightStore.addRecentItem({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
path: item.path,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
spotlightStore.close()
|
||||||
|
if (item.path) {
|
||||||
|
router.push(item.path)
|
||||||
|
} else if (item.content) {
|
||||||
|
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' }) {
|
||||||
|
spotlightStore.close()
|
||||||
|
if (item.path) {
|
||||||
|
router.push(item.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.type === 'learn') {
|
||||||
|
for (const s of helpTree) {
|
||||||
|
const helpItem = s.items.find((i) => i.id === item.id)
|
||||||
|
if (helpItem?.content) {
|
||||||
|
spotlightStore.showHelpModal({ title: helpItem.label, content: helpItem.content, relatedPath: helpItem.relatedPath })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
spotlightStore.close()
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
spotlightStore.setSelectedIndex(
|
||||||
|
Math.min(spotlightStore.selectedIndex + 1, Math.max(0, selectableCount.value - 1))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
spotlightStore.setSelectedIndex(Math.max(spotlightStore.selectedIndex - 1, 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = spotlightStore.selectedIndex
|
||||||
|
if (query.value.trim()) {
|
||||||
|
const item = filteredItems.value[idx]
|
||||||
|
if (item) selectItem(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (idx < recentOffset.value) {
|
||||||
|
const item = spotlightStore.recentItems[idx]
|
||||||
|
if (item) selectRecent(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const helpIdx = idx - recentOffset.value
|
||||||
|
let count = 0
|
||||||
|
for (const s of helpTree) {
|
||||||
|
for (const item of s.items) {
|
||||||
|
if (count === helpIdx) {
|
||||||
|
selectHelpItem(s, item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanelMouseDown(e: MouseEvent) {
|
||||||
|
if (!dragHandleRef.value?.contains(e.target as Node)) return
|
||||||
|
isDragging.value = true
|
||||||
|
const rect = panelRef.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const currentX = savedPosition.value?.x ?? 0
|
||||||
|
const currentY = savedPosition.value?.y ?? 0
|
||||||
|
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!dragStart.value) return
|
||||||
|
const dx = e.clientX - dragStart.value.x
|
||||||
|
const dy = e.clientY - dragStart.value.y
|
||||||
|
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
dragStart.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => spotlightStore.isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
query.value = ''
|
||||||
|
loadSavedPosition()
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
spotlightStore.setSelectedIndex(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[query, filteredItems],
|
||||||
|
() => {
|
||||||
|
spotlightStore.setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSavedPosition()
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.spotlight-enter-active,
|
||||||
|
.spotlight-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.spotlight-enter-from,
|
||||||
|
.spotlight-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
170
neode-ui/src/composables/useControllerNav.ts
Normal file
170
neode-ui/src/composables/useControllerNav.ts
Normal file
@ -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<HTMLElement>(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<typeof setTimeout> | null = null
|
||||||
|
let pollIntervalId: ReturnType<typeof setInterval> | 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
86
neode-ui/src/composables/useMessageToast.ts
Normal file
86
neode-ui/src/composables/useMessageToast.ts
Normal file
@ -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<ReceivedMessage[]>([])
|
||||||
|
const lastMessageCount = ref(0)
|
||||||
|
const loadingMessages = ref(false)
|
||||||
|
const toastMessage = ref<{ show: boolean; text: string }>({ show: false, text: '' })
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
20
neode-ui/src/composables/useOnboarding.ts
Normal file
20
neode-ui/src/composables/useOnboarding.ts
Normal file
@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
return await rpcClient.isOnboardingComplete()
|
||||||
|
} catch {
|
||||||
|
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeOnboarding(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rpcClient.completeOnboarding()
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem('neode_onboarding_complete', '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
97
neode-ui/src/data/helpTree.ts
Normal file
97
neode-ui/src/data/helpTree.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -11,30 +11,7 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirect: (_to) => {
|
component: () => import('../views/RootRedirect.vue'),
|
||||||
// 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'
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
|
|||||||
@ -164,6 +164,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
'update-progress': null,
|
'update-progress': null,
|
||||||
},
|
},
|
||||||
'lan-address': null,
|
'lan-address': null,
|
||||||
|
'tor-address': null,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
'wifi-ssids': [],
|
'wifi-ssids': [],
|
||||||
'zram-enabled': false,
|
'zram-enabled': false,
|
||||||
|
|||||||
23
neode-ui/src/stores/controller.ts
Normal file
23
neode-ui/src/stores/controller.ts
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
99
neode-ui/src/stores/spotlight.ts
Normal file
99
neode-ui/src/stores/spotlight.ts
Normal file
@ -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<RecentItem[]>([])
|
||||||
|
|
||||||
|
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<RecentItem, 'timestamp'>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -2,6 +2,12 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 */
|
/* Global glassmorphism utilities */
|
||||||
@layer components {
|
@layer components {
|
||||||
.glass {
|
.glass {
|
||||||
@ -32,6 +38,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-button {
|
.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);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
-webkit-backdrop-filter: blur(18px);
|
-webkit-backdrop-filter: blur(18px);
|
||||||
@ -39,6 +52,34 @@
|
|||||||
color: rgba(255, 255, 255, 0.9);
|
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 containers - transparent to black */
|
||||||
.gradient-card {
|
.gradient-card {
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
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;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-gradient-border img {
|
.logo-gradient-border img,
|
||||||
|
.logo-gradient-border svg {
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@ -13,6 +13,8 @@ export interface ServerInfo {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
'status-info': StatusInfo
|
'status-info': StatusInfo
|
||||||
'lan-address': string | null
|
'lan-address': string | null
|
||||||
|
'tor-address': string | null
|
||||||
|
'node-address'?: string
|
||||||
unread: number
|
unread: number
|
||||||
'wifi-ssids': string[]
|
'wifi-ssids': string[]
|
||||||
'zram-enabled': boolean
|
'zram-enabled': boolean
|
||||||
@ -48,6 +50,7 @@ export const PackageState = {
|
|||||||
Installed: 'installed',
|
Installed: 'installed',
|
||||||
Stopping: 'stopping',
|
Stopping: 'stopping',
|
||||||
Stopped: 'stopped',
|
Stopped: 'stopped',
|
||||||
|
Exited: 'exited',
|
||||||
Starting: 'starting',
|
Starting: 'starting',
|
||||||
Running: 'running',
|
Running: 'running',
|
||||||
Restarting: 'restarting',
|
Restarting: 'restarting',
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
|||||||
'static-files': {
|
'static-files': {
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
instructions: 'Federated Bitcoin mint',
|
instructions: 'Federated Bitcoin mint',
|
||||||
icon: '/assets/img/icon-fedimint.jpeg'
|
icon: '/assets/img/app-icons/fedimint.png'
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'fedimint',
|
id: 'fedimint',
|
||||||
@ -216,7 +216,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
|||||||
'interface-addresses': {
|
'interface-addresses': {
|
||||||
main: {
|
main: {
|
||||||
'tor-address': 'fedimint.onion',
|
'tor-address': 'fedimint.onion',
|
||||||
'lan-address': 'http://localhost:8173'
|
'lan-address': 'http://localhost:8175'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
status: ServiceStatus.Running
|
status: ServiceStatus.Running
|
||||||
|
|||||||
@ -268,6 +268,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Access (LAN + Tor) Card -->
|
||||||
|
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-white/80 font-medium">LAN</p>
|
||||||
|
<a
|
||||||
|
:href="lanUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-400 hover:text-blue-300 text-sm break-all"
|
||||||
|
>
|
||||||
|
{{ interfaceAddresses['lan-address'] }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isRealOnionAddress(interfaceAddresses['tor-address'])" class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-white/80 font-medium">Tor</p>
|
||||||
|
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||||
|
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Requirements Card -->
|
<!-- Requirements Card -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6">
|
||||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||||
@ -407,17 +440,69 @@ const route = useRoute()
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
const appId = computed(() => route.params.id as string)
|
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<string, string> = {
|
||||||
|
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(() => {
|
const pkg = computed(() => {
|
||||||
// First check real packages
|
const routeId = appId.value
|
||||||
if (store.packages[appId.value]) {
|
const packageKey = resolvePackageKey(routeId)
|
||||||
return store.packages[appId.value]
|
// 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
|
// Fall back to dummy apps
|
||||||
if (dummyApps[appId.value]) {
|
if (dummyApps[routeId]) {
|
||||||
return dummyApps[appId.value]
|
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({
|
const uninstallModal = ref({
|
||||||
@ -543,8 +628,8 @@ function launchApp() {
|
|||||||
prod: 'http://localhost:8080'
|
prod: 'http://localhost:8080'
|
||||||
},
|
},
|
||||||
'fedimint': {
|
'fedimint': {
|
||||||
dev: 'http://localhost:8173',
|
dev: 'http://localhost:8175',
|
||||||
prod: 'http://localhost:8173'
|
prod: 'http://192.168.1.228:8175'
|
||||||
},
|
},
|
||||||
'morphos-server': {
|
'morphos-server': {
|
||||||
dev: 'http://localhost:8081',
|
dev: 'http://localhost:8081',
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="[id, pkg] in sortedPackageEntries"
|
v-for="[id, pkg] in sortedPackageEntries"
|
||||||
:key="id"
|
:key="id"
|
||||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative"
|
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||||
@click="goToApp(id as string)"
|
@click="goToApp(id as string)"
|
||||||
>
|
>
|
||||||
<!-- Uninstall Icon -->
|
<!-- Uninstall Icon -->
|
||||||
@ -48,8 +48,8 @@
|
|||||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
||||||
{{ pkg.manifest.title }}
|
{{ pkg.manifest.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-white/70 mb-2 truncate">
|
<p class="text-sm text-white/70 mb-2 truncate">
|
||||||
|
|||||||
@ -44,11 +44,9 @@
|
|||||||
<aside class="hidden md:flex w-[256px] border-r border-glass-border shadow-glass-sm flex-shrink-0 relative flex-col" style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
<aside class="hidden md:flex w-[256px] border-r border-glass-border shadow-glass-sm flex-shrink-0 relative flex-col" style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
||||||
<div class="p-6 flex-1">
|
<div class="p-6 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-8">
|
<div class="flex items-center gap-3 mb-8">
|
||||||
<div class="logo-gradient-border flex-shrink-0">
|
<AnimatedLogo />
|
||||||
<img src="/assets/icon/favico-black.svg" alt="Neode" class="w-14 h-14" />
|
<div class="min-w-0 flex-1">
|
||||||
</div>
|
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-white">{{ serverName }}</h2>
|
|
||||||
<p class="text-xs text-white/60">v{{ version }}</p>
|
<p class="text-xs text-white/60">v{{ version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -76,6 +74,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Controller indicator - Desktop sidebar -->
|
||||||
|
<div class="px-6 pb-2">
|
||||||
|
<ControllerIndicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Section - Desktop Only -->
|
<!-- User Section - Desktop Only -->
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<button
|
<button
|
||||||
@ -114,6 +117,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New message toast (top right, glassmorphic) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="toast">
|
||||||
|
<div
|
||||||
|
v-if="toastMessage.show"
|
||||||
|
@click="messageToast.dismissToastAndOpenMessages"
|
||||||
|
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
|
||||||
|
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-white">New message</p>
|
||||||
|
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
|
||||||
|
<p class="mt-1 text-xs text-orange-400">Click to view</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||||
<div
|
<div
|
||||||
v-if="showAppsTabs"
|
v-if="showAppsTabs"
|
||||||
@ -204,12 +231,11 @@
|
|||||||
<div :key="route.path" class="view-wrapper">
|
<div :key="route.path" class="view-wrapper">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'px-4 pt-4 md:px-8 md:pt-8 overflow-y-auto h-full',
|
'px-4 pt-4 pb-4 md:px-8 md:pt-8 md:pb-8 overflow-y-auto h-full',
|
||||||
needsMobileBackButtonSpace
|
needsMobileBackButtonSpace
|
||||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)]'
|
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
|
||||||
: undefined
|
: undefined
|
||||||
]"
|
]"
|
||||||
:style="contentPaddingBottomStyle"
|
|
||||||
>
|
>
|
||||||
<component :is="Component" class="view-container" />
|
<component :is="Component" class="view-container" />
|
||||||
</div>
|
</div>
|
||||||
@ -262,8 +288,13 @@
|
|||||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
import { useMessageToast } from '@/composables/useMessageToast'
|
||||||
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const messageToast = useMessageToast()
|
||||||
|
const toastMessage = messageToast.toastMessage
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
@ -437,9 +468,6 @@ watch(() => route.path, (newPath) => {
|
|||||||
|
|
||||||
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
||||||
|
|
||||||
const contentPaddingBottomStyle = computed(() =>
|
|
||||||
(typeof window !== 'undefined' && window.innerWidth >= 768) ? { paddingBottom: '0' } : undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show persistent tabs for Apps/Marketplace on mobile
|
// Show persistent tabs for Apps/Marketplace on mobile
|
||||||
const showAppsTabs = computed(() => {
|
const showAppsTabs = computed(() => {
|
||||||
@ -501,6 +529,7 @@ function updateNetworkTabIndicator() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateTabBarHeight()
|
updateTabBarHeight()
|
||||||
|
messageToast.startPolling()
|
||||||
updateAppsTabIndicator()
|
updateAppsTabIndicator()
|
||||||
updateNetworkTabIndicator()
|
updateNetworkTabIndicator()
|
||||||
|
|
||||||
@ -513,6 +542,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', updateTabBarHeight)
|
window.removeEventListener('resize', updateTabBarHeight)
|
||||||
|
messageToast.stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch route changes to update indicator position
|
// Watch route changes to update indicator position
|
||||||
|
|||||||
@ -18,8 +18,8 @@
|
|||||||
<!-- Section Overviews -->
|
<!-- Section Overviews -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- My Apps Overview -->
|
<!-- My Apps Overview -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||||
@ -34,7 +34,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||||
<div class="p-4 bg-white/5 rounded-lg">
|
<div class="p-4 bg-white/5 rounded-lg">
|
||||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||||
@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard/marketplace"
|
to="/dashboard/marketplace"
|
||||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||||
@ -62,8 +62,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cloud Overview -->
|
<!-- Cloud Overview -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||||
@ -78,7 +78,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||||
<div class="p-4 bg-white/5 rounded-lg">
|
<div class="p-4 bg-white/5 rounded-lg">
|
||||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard/cloud"
|
to="/dashboard/cloud"
|
||||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||||
@ -106,8 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Overview -->
|
<!-- Network Overview -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||||
@ -122,7 +122,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-4">
|
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
@ -146,7 +146,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard/server"
|
to="/dashboard/server"
|
||||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||||
@ -165,8 +165,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web5 Overview -->
|
<!-- Web5 Overview -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||||
@ -181,7 +181,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-4">
|
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
@ -205,7 +205,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard/web5"
|
to="/dashboard/web5"
|
||||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||||
|
|||||||
@ -91,26 +91,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Tabs (Desktop only) -->
|
<!-- Category Tabs + Search (Desktop only) -->
|
||||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg flex-wrap gap-2">
|
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
v-for="category in categories"
|
<button
|
||||||
:key="category.id"
|
v-for="category in categoriesWithApps"
|
||||||
@click="selectedCategory = category.id"
|
:key="category.id"
|
||||||
:class="[
|
@click="selectedCategory = category.id"
|
||||||
'px-6 py-2 rounded-lg font-medium transition-all',
|
:class="[
|
||||||
selectedCategory === category.id
|
'px-6 py-2 rounded-lg font-medium transition-all',
|
||||||
? 'bg-white/20 text-white'
|
selectedCategory === category.id
|
||||||
: 'text-white/60 hover:text-white/80'
|
? 'bg-white/20 text-white'
|
||||||
]"
|
: 'text-white/60 hover:text-white/80'
|
||||||
>
|
]"
|
||||||
{{ category.name }}
|
>
|
||||||
</button>
|
{{ category.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar (Mobile - placeholder for later) -->
|
||||||
<!-- Search Bar -->
|
<div class="md:hidden mb-6">
|
||||||
<div class="mb-6 pt-0 md:pt-0">
|
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@ -297,7 +304,7 @@
|
|||||||
<!-- Category Grid -->
|
<!-- Category Grid -->
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
v-for="category in categories"
|
v-for="category in categoriesWithApps"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
@click="selectedCategory = category.id; showFilterModal = false"
|
@click="selectedCategory = category.id; showFilterModal = false"
|
||||||
:class="[
|
:class="[
|
||||||
@ -521,6 +528,15 @@ const allApps = computed(() => {
|
|||||||
return [...local, ...community]
|
return [...local, ...community]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Only show categories that have at least one app
|
||||||
|
const categoriesWithApps = computed(() => {
|
||||||
|
const apps = allApps.value
|
||||||
|
return categories.filter(cat => {
|
||||||
|
if (cat.id === 'all') return apps.length > 0
|
||||||
|
return apps.some(app => app.category === cat.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Filtered apps by category and search
|
// Filtered apps by category and search
|
||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let apps = allApps.value
|
let apps = allApps.value
|
||||||
@ -801,11 +817,11 @@ function getCuratedAppList() {
|
|||||||
{
|
{
|
||||||
id: 'fedimint',
|
id: 'fedimint',
|
||||||
title: 'Fedimint',
|
title: 'Fedimint',
|
||||||
version: '0.3.0',
|
version: '0.10.0',
|
||||||
description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.',
|
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
|
||||||
icon: '/assets/img/icon-fedimint.jpeg',
|
icon: '/assets/img/app-icons/fedimint.png',
|
||||||
author: 'Fedimint',
|
author: 'Fedimint',
|
||||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.3.0',
|
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
|
||||||
manifestUrl: null,
|
manifestUrl: null,
|
||||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="flex flex-col items-center gap-6 mb-6">
|
<div class="flex flex-col items-center gap-6 mb-6">
|
||||||
|
<!-- Error message -->
|
||||||
|
<p v-if="errorMessage" class="text-red-400 text-sm mb-4">{{ errorMessage }}</p>
|
||||||
<!-- Generate Button (if no DID yet) -->
|
<!-- Generate Button (if no DID yet) -->
|
||||||
<button
|
<button
|
||||||
v-if="!generatedDid"
|
v-if="!generatedDid"
|
||||||
@ -75,7 +77,8 @@
|
|||||||
<button
|
<button
|
||||||
v-if="generatedDid"
|
v-if="generatedDid"
|
||||||
@click="proceed"
|
@click="proceed"
|
||||||
class="path-action-button path-action-button--continue"
|
:disabled="generatedDid.includes('...')"
|
||||||
|
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</button>
|
</button>
|
||||||
@ -87,49 +90,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const generatedDid = ref<string>('')
|
const generatedDid = ref<string>('')
|
||||||
const isGenerating = ref(false)
|
const isGenerating = ref(false)
|
||||||
|
const errorMessage = ref<string>('')
|
||||||
|
|
||||||
async function generateDid() {
|
async function generateDid() {
|
||||||
isGenerating.value = true
|
isGenerating.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
// Simulate DID generation (replace with actual implementation)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
||||||
|
|
||||||
// Mock DID generation - in production, this would call the backend
|
|
||||||
const mockDid = `did:key:z6Mk${generateRandomString(44)}`
|
|
||||||
generatedDid.value = mockDid
|
|
||||||
|
|
||||||
// Store in localStorage
|
|
||||||
localStorage.setItem('neode_did', mockDid)
|
|
||||||
|
|
||||||
isGenerating.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateRandomString(length: number): string {
|
try {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||||
let result = ''
|
generatedDid.value = did
|
||||||
for (let i = 0; i < length; i++) {
|
localStorage.setItem('neode_did', did)
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
|
||||||
|
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
|
||||||
|
if (!generatedDid.value) {
|
||||||
|
generatedDid.value = 'did:key:z6Mk... (connect to server)'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function proceed() {
|
function proceed() {
|
||||||
// Store DID state and continue to backup
|
if (generatedDid.value && !generatedDid.value.includes('...')) {
|
||||||
if (generatedDid.value) {
|
|
||||||
localStorage.setItem('neode_did_state', JSON.stringify({
|
|
||||||
did: generatedDid.value,
|
|
||||||
kid: 'kid:mock'
|
|
||||||
}))
|
|
||||||
router.push('/onboarding/backup')
|
router.push('/onboarding/backup')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipForNow() {
|
function skipForNow() {
|
||||||
// Skip to backup screen
|
|
||||||
router.push('/onboarding/backup')
|
router.push('/onboarding/backup')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -88,6 +88,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const selected = ref<string | null>(null)
|
const selected = ref<string | null>(null)
|
||||||
@ -96,13 +97,9 @@ function selectOption(option: string) {
|
|||||||
selected.value = option
|
selected.value = option
|
||||||
}
|
}
|
||||||
|
|
||||||
function proceed() {
|
async function proceed() {
|
||||||
if (selected.value) {
|
if (selected.value) {
|
||||||
// Mark onboarding as complete
|
await completeOnboarding()
|
||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
|
||||||
|
|
||||||
// For now, just go to login
|
|
||||||
// In a real app, you'd have different flows for each option
|
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,6 +83,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const verified = ref(false)
|
const verified = ref(false)
|
||||||
@ -91,15 +92,14 @@ const signature = ref('')
|
|||||||
|
|
||||||
async function signChallenge() {
|
async function signChallenge() {
|
||||||
isSigning.value = true
|
isSigning.value = true
|
||||||
|
|
||||||
// Simulate signing challenge
|
// Simulate signing challenge
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
// Mock signature generation
|
|
||||||
const mockSignature = generateMockSignature()
|
const mockSignature = generateMockSignature()
|
||||||
signature.value = mockSignature
|
signature.value = mockSignature
|
||||||
verified.value = true
|
verified.value = true
|
||||||
|
|
||||||
isSigning.value = false
|
isSigning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,16 +112,14 @@ function generateMockSignature(): string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function proceed() {
|
async function proceed() {
|
||||||
// Mark onboarding as complete
|
await completeOnboarding()
|
||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
router.push('/login')
|
||||||
router.push('/onboarding/done')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipForNow() {
|
async function skipForNow() {
|
||||||
// Mark onboarding as complete
|
await completeOnboarding()
|
||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
router.push('/login')
|
||||||
router.push('/onboarding/done')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
30
neode-ui/src/views/RootRedirect.vue
Normal file
30
neode-ui/src/views/RootRedirect.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-black/40">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<svg class="animate-spin h-10 w-10 text-white/80" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-white/60 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const devMode = import.meta.env.VITE_DEV_MODE
|
||||||
|
if (devMode === 'setup' || devMode === 'existing') {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenOnboarding = await isOnboardingComplete()
|
||||||
|
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -8,22 +8,22 @@
|
|||||||
|
|
||||||
<!-- Quick Actions Container -->
|
<!-- Quick Actions Container -->
|
||||||
<div class="glass-card p-6 mb-6">
|
<div class="glass-card p-6 mb-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Service Status -->
|
<!-- Service Status -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||||
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Services</p>
|
<p class="text-sm font-medium text-white">Services</p>
|
||||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="restartServices"
|
@click="restartServices"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
:disabled="restarting"
|
:disabled="restarting"
|
||||||
>
|
>
|
||||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||||
@ -31,20 +31,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connectivity Status -->
|
<!-- Connectivity Status -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="connectivityStatus === 'connected' ? 'bg-green-400' : connectivityStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="connectivityStatus === 'connected' ? 'bg-green-400' : connectivityStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-400'"></div>
|
||||||
<div v-if="connectivityStatus === 'connected'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div v-if="connectivityStatus === 'connected'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Connectivity</p>
|
<p class="text-sm font-medium text-white">Connectivity</p>
|
||||||
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="checkConnectivity"
|
@click="checkConnectivity"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
:disabled="checkingConnectivity"
|
:disabled="checkingConnectivity"
|
||||||
>
|
>
|
||||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||||
@ -52,19 +52,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-Sync Toggle -->
|
<!-- Auto-Sync Toggle -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="toggleAutoSync"
|
@click="toggleAutoSync"
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors self-start"
|
||||||
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -75,19 +75,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs & Diagnostics -->
|
<!-- Logs & Diagnostics -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Logs</p>
|
<p class="text-sm font-medium text-white">Logs</p>
|
||||||
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="viewLogs"
|
@click="viewLogs"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
@ -98,8 +98,8 @@
|
|||||||
<!-- Overview Cards -->
|
<!-- Overview Cards -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Local Network Card -->
|
<!-- Local Network Card -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -153,14 +153,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||||
Manage Local Network
|
Manage Local Network
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web3 Card -->
|
<!-- Web3 Card -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
@ -172,7 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -214,7 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||||
Manage Web3 Services
|
Manage Web3 Services
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-8">
|
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
<div>
|
||||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||||
|
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||||
|
</div>
|
||||||
|
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
<ControllerIndicator />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Section -->
|
<!-- Account Section -->
|
||||||
@ -43,8 +49,131 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
|
||||||
|
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||||
|
<!-- DID -->
|
||||||
|
<div v-if="userDid">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||||
|
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="copyOnionAddress"
|
||||||
|
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg v-if="!copiedOnion" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||||
|
<span v-if="!copiedOnion">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<button
|
||||||
|
@click="showChangePasswordModal = true"
|
||||||
|
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<span>Change Password</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showChangePasswordModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
|
@click.self="showChangePasswordModal = false"
|
||||||
|
>
|
||||||
|
<div class="glass-card p-6 max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
|
||||||
|
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
|
||||||
|
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
|
||||||
|
<input
|
||||||
|
v-model="changePasswordForm.currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
|
||||||
|
<input
|
||||||
|
v-model="changePasswordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
placeholder="12+ chars, upper, lower, digit, special"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
v-model="changePasswordForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||||
|
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
|
||||||
|
Also update SSH password (recommended)
|
||||||
|
</label>
|
||||||
|
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||||
|
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="changingPassword"
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ changingPassword ? 'Updating...' : 'Update Password' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeChangePasswordModal"
|
||||||
|
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Logout Button -->
|
<!-- Logout Button -->
|
||||||
<button
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
@ -72,15 +201,122 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
|
||||||
const serverName = computed(() => store.serverName)
|
const serverName = computed(() => store.serverName)
|
||||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||||
|
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||||
|
const torAddressFromRpc = ref<string | null>(null)
|
||||||
|
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
|
||||||
|
const userDid = computed(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('neode_did') || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copiedOnion = ref(false)
|
||||||
|
const showChangePasswordModal = ref(false)
|
||||||
|
const changingPassword = ref(false)
|
||||||
|
const changePasswordError = ref('')
|
||||||
|
const changePasswordSuccess = ref('')
|
||||||
|
const changePasswordForm = ref({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
alsoChangeSsh: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function validatePasswordStrength(pw: string): string | null {
|
||||||
|
if (pw.length < 12) return 'Password must be at least 12 characters'
|
||||||
|
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
|
||||||
|
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
|
||||||
|
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
|
||||||
|
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
changePasswordError.value = ''
|
||||||
|
changePasswordSuccess.value = ''
|
||||||
|
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
changePasswordError.value = 'All fields are required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
changePasswordError.value = 'New passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const strengthError = validatePasswordStrength(newPassword)
|
||||||
|
if (strengthError) {
|
||||||
|
changePasswordError.value = strengthError
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changingPassword.value = true
|
||||||
|
try {
|
||||||
|
await rpcClient.changePassword({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
alsoChangeSsh,
|
||||||
|
})
|
||||||
|
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
|
||||||
|
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||||
|
setTimeout(() => {
|
||||||
|
closeChangePasswordModal()
|
||||||
|
}, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
||||||
|
} finally {
|
||||||
|
changingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyOnionAddress() {
|
||||||
|
const addr = serverTorAddress.value
|
||||||
|
if (!addr) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(addr)
|
||||||
|
copiedOnion.value = true
|
||||||
|
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = addr
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
copiedOnion.value = true
|
||||||
|
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChangePasswordModal() {
|
||||||
|
showChangePasswordModal.value = false
|
||||||
|
changePasswordError.value = ''
|
||||||
|
changePasswordSuccess.value = ''
|
||||||
|
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!serverTorAddressFromStore.value) {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.getTorAddress()
|
||||||
|
torAddressFromRpc.value = res.tor_address ?? null
|
||||||
|
} catch {
|
||||||
|
// Ignore - tor address may not be available yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await store.logout()
|
await store.logout()
|
||||||
|
|||||||
@ -8,14 +8,14 @@
|
|||||||
|
|
||||||
<!-- Quick Actions Container -->
|
<!-- Quick Actions Container -->
|
||||||
<div class="glass-card p-6 mb-6">
|
<div class="glass-card p-6 mb-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
<!-- Networking Profits -->
|
<!-- Networking Profits -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Networking Profits</p>
|
<p class="text-sm font-medium text-white">Networking Profits</p>
|
||||||
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
||||||
</div>
|
</div>
|
||||||
@ -23,40 +23,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DID Status -->
|
<!-- DID Status -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
|
||||||
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-white">DID Status</p>
|
<p class="text-sm font-medium text-white">DID Status</p>
|
||||||
<p class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
|
||||||
|
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="manageDIDs"
|
@click="manageDIDs"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wallet Connection -->
|
<!-- Wallet Connection -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||||
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Wallet</p>
|
<p class="text-sm font-medium text-white">Wallet</p>
|
||||||
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="connectWallet"
|
@click="connectWallet"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
:disabled="connectingWallet"
|
:disabled="connectingWallet"
|
||||||
>
|
>
|
||||||
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
||||||
@ -64,32 +65,102 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nostr Relay Status -->
|
<!-- Nostr Relay Status -->
|
||||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="nostrRelaysConnected > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="nostrRelaysConnected > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||||
<div v-if="nostrRelaysConnected > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div v-if="nostrRelaysConnected > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
||||||
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="manageRelays"
|
@click="manageRelays"
|
||||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Nodes -->
|
||||||
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||||
|
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-white">Connected Nodes</p>
|
||||||
|
<p class="text-xs text-white/60">{{ connectedNodesCount }} peer{{ connectedNodesCount !== 1 ? 's' : '' }} known</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showSendMessageModal = true"
|
||||||
|
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Message Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showSendMessageModal = false">
|
||||||
|
<div class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Send Message (over Tor)</h3>
|
||||||
|
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/80 mb-2">To</label>
|
||||||
|
<select
|
||||||
|
v-model="sendMessageTo"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a peer...</option>
|
||||||
|
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||||
|
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||||
|
<textarea
|
||||||
|
v-model="sendMessageText"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
@click="sendMessage"
|
||||||
|
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{{ sendingMessage ? 'Sending...' : 'Send' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showSendMessageModal = false"
|
||||||
|
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
|
||||||
|
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Core Services Overview Cards -->
|
<!-- Core Services Overview Cards -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
<!-- Bitcoin Domain Name Portfolio -->
|
<!-- Bitcoin Domain Name Portfolio -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
@ -101,7 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -133,14 +204,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||||
Manage Domains
|
Manage Domains
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web5 Wallet -->
|
<!-- Web5 Wallet -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
@ -152,7 +223,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||||
@ -182,14 +253,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||||
Open Wallet
|
Open Wallet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nostr Relays -->
|
<!-- Nostr Relays -->
|
||||||
<div class="glass-card p-6">
|
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
@ -201,7 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 flex-1 min-h-0">
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -233,10 +304,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||||
Manage Relays
|
Manage Relays
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Nodes (P2P over Tor) -->
|
||||||
|
<div ref="nodesContainerRef" class="glass-card p-6 lg:col-span-3 scroll-mt-24">
|
||||||
|
<div class="flex items-start gap-4 mb-4">
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Connected Nodes</h2>
|
||||||
|
<p class="text-white/70 text-sm mb-4">Peer nodes discovered via Nostr. Messages sent over Tor.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="loadPeers"
|
||||||
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{{ loadingPeers ? '...' : 'Refresh' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Peers | Messages -->
|
||||||
|
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||||
|
<button
|
||||||
|
@click="nodesContainerTab = 'peers'"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||||
|
:class="nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||||
|
>
|
||||||
|
Peers
|
||||||
|
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchToMessagesTab"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
||||||
|
:class="nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||||
|
>
|
||||||
|
Messages
|
||||||
|
<span v-if="receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'">({{ receivedMessages.length }})</span>
|
||||||
|
<span v-if="unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peers tab -->
|
||||||
|
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||||
|
No peers yet. Add a peer manually or use Discover to find nodes on Nostr.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="p in peers"
|
||||||
|
:key="p.pubkey"
|
||||||
|
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || p.pubkey.slice(0, 16) + '...' }}</p>
|
||||||
|
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="showSendMessageModal = true; sendMessageTo = p.onion"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages tab -->
|
||||||
|
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
|
||||||
|
Loading messages...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||||
|
No messages yet. Messages from peers will appear here.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(m, idx) in receivedMessages"
|
||||||
|
:key="idx"
|
||||||
|
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ m.from_pubkey.slice(0, 16) }}...</p>
|
||||||
|
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="nodesContainerTab === 'peers'"
|
||||||
|
@click="discoverAndAddPeers"
|
||||||
|
:disabled="discovering"
|
||||||
|
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ discovering ? 'Discovering...' : 'Discover Nodes on Nostr' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="loadReceivedMessages"
|
||||||
|
:disabled="loadingMessages"
|
||||||
|
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loadingMessages ? 'Loading...' : 'Refresh Messages' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Protocol Overview Cards -->
|
<!-- Protocol Overview Cards -->
|
||||||
@ -449,10 +626,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useMessageToast } from '@/composables/useMessageToast'
|
||||||
|
|
||||||
// DID Status: 'active' | 'inactive' | 'pending'
|
const route = useRoute()
|
||||||
const didStatus = ref<'active' | 'inactive' | 'pending'>('active')
|
const messageToast = useMessageToast()
|
||||||
|
|
||||||
|
const userDid = computed(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('neode_did') || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DID Status: 'active' when user has DID, else 'inactive'
|
||||||
|
const didStatus = computed<'active' | 'inactive' | 'pending'>(() =>
|
||||||
|
userDid.value ? 'active' : 'inactive'
|
||||||
|
)
|
||||||
|
|
||||||
// DWN Sync Status: 'synced' | 'syncing' | 'error'
|
// DWN Sync Status: 'synced' | 'syncing' | 'error'
|
||||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
||||||
@ -465,6 +658,130 @@ const connectingWallet = ref(false)
|
|||||||
// Nostr Relays
|
// Nostr Relays
|
||||||
const nostrRelaysConnected = ref(8)
|
const nostrRelaysConnected = ref(8)
|
||||||
|
|
||||||
|
// Connected Nodes (peers)
|
||||||
|
const peers = ref<Array<{ onion: string; pubkey: string; name?: string }>>([])
|
||||||
|
const loadingPeers = ref(false)
|
||||||
|
const peerReachable = ref<Record<string, boolean>>({})
|
||||||
|
const connectedNodesCount = computed(() => peers.value.length)
|
||||||
|
|
||||||
|
// Send Message modal
|
||||||
|
const showSendMessageModal = ref(false)
|
||||||
|
const sendMessageTo = ref('')
|
||||||
|
const sendMessageText = ref('')
|
||||||
|
const sendingMessage = ref(false)
|
||||||
|
const sendMessageError = ref('')
|
||||||
|
const sendMessageSuccess = ref('')
|
||||||
|
const discovering = ref(false)
|
||||||
|
|
||||||
|
// Connected Nodes container: tabs + messages (uses shared composable for polling from Dashboard)
|
||||||
|
const nodesContainerRef = ref<HTMLElement | null>(null)
|
||||||
|
const nodesContainerTab = ref<'peers' | 'messages'>('peers')
|
||||||
|
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||||
|
|
||||||
|
function formatMessageTime(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
if (diff < 60000) return 'Just now'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||||
|
return d.toLocaleDateString()
|
||||||
|
} catch {
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToMessagesTab() {
|
||||||
|
nodesContainerTab.value = 'messages'
|
||||||
|
markAsRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPeers() {
|
||||||
|
loadingPeers.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.listPeers()
|
||||||
|
peers.value = res.peers || []
|
||||||
|
for (const p of peers.value) {
|
||||||
|
try {
|
||||||
|
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||||
|
peerReachable.value[p.onion] = check.reachable
|
||||||
|
} catch {
|
||||||
|
peerReachable.value[p.onion] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load peers:', e)
|
||||||
|
} finally {
|
||||||
|
loadingPeers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
|
||||||
|
sendingMessage.value = true
|
||||||
|
sendMessageError.value = ''
|
||||||
|
sendMessageSuccess.value = ''
|
||||||
|
try {
|
||||||
|
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
|
||||||
|
sendMessageSuccess.value = 'Message sent over Tor!'
|
||||||
|
sendMessageText.value = ''
|
||||||
|
setTimeout(() => {
|
||||||
|
showSendMessageModal.value = false
|
||||||
|
sendMessageSuccess.value = ''
|
||||||
|
}, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
sendMessageError.value = e instanceof Error ? e.message : 'Failed to send'
|
||||||
|
} finally {
|
||||||
|
sendingMessage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverAndAddPeers() {
|
||||||
|
discovering.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.discoverNodes()
|
||||||
|
const nodes = res.nodes || []
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.onion && n.pubkey) {
|
||||||
|
try {
|
||||||
|
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
||||||
|
} catch {
|
||||||
|
// may already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadPeers()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Discover failed:', e)
|
||||||
|
} finally {
|
||||||
|
discovering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPeers()
|
||||||
|
loadReceivedMessages()
|
||||||
|
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
|
||||||
|
if (route.query.tab === 'messages') {
|
||||||
|
nodesContainerTab.value = 'messages'
|
||||||
|
markAsRead()
|
||||||
|
nextTick(() => {
|
||||||
|
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.tab, (tab) => {
|
||||||
|
if (tab === 'messages') {
|
||||||
|
nodesContainerTab.value = 'messages'
|
||||||
|
markAsRead()
|
||||||
|
nextTick(() => {
|
||||||
|
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function manageDIDs() {
|
function manageDIDs() {
|
||||||
// TODO: Navigate to DID management or open modal
|
// TODO: Navigate to DID management or open modal
|
||||||
console.log('Managing DIDs...')
|
console.log('Managing DIDs...')
|
||||||
@ -506,3 +823,4 @@ function manageRelays() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
7
scripts/deploy-config.example
Normal file
7
scripts/deploy-config.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Deploy config (copy to deploy-config.sh and set your password)
|
||||||
|
# deploy-config.sh is gitignored so the password is not committed.
|
||||||
|
#
|
||||||
|
# cp scripts/deploy-config.example scripts/deploy-config.sh
|
||||||
|
# Edit deploy-config.sh and set ARCHIPELAGO_PASSWORD
|
||||||
|
#
|
||||||
|
export ARCHIPELAGO_PASSWORD='your_password_here'
|
||||||
@ -5,7 +5,8 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/deploy-to-target.sh # Sync and rebuild
|
# ./scripts/deploy-to-target.sh # Sync and rebuild
|
||||||
# ./scripts/deploy-to-target.sh --quick # Sync only, no rebuild
|
# ./scripts/deploy-to-target.sh --quick # Sync only, no rebuild
|
||||||
# ./scripts/deploy-to-target.sh --live # Deploy to live system
|
# ./scripts/deploy-to-target.sh --live # Deploy to live system (default: 192.168.1.228)
|
||||||
|
# ./scripts/deploy-to-target.sh --both # Deploy to 228, then copy to 198
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@ -13,12 +14,16 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Load deploy config (password etc.) - deploy-config.sh is gitignored
|
||||||
|
[ -f "$SCRIPT_DIR/deploy-config.sh" ] && . "$SCRIPT_DIR/deploy-config.sh"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
TARGET_DIR="/home/archipelago/archy"
|
TARGET_DIR="/home/archipelago/archy"
|
||||||
# Password for non-interactive SSH/rsync (dev server only). See .cursor/rules/Development-Workflow.md
|
# Password for non-interactive SSH/rsync. Set in deploy-config.sh or ARCHIPELAGO_PASSWORD env.
|
||||||
ARCHIPELAGO_PASSWORD="${ARCHIPELAGO_PASSWORD:-archipelago}"
|
ARCHIPELAGO_PASSWORD="${ARCHIPELAGO_PASSWORD:-archipelago}"
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no"
|
# Force password auth when using sshpass (avoids "Permission denied" from SSH key mismatch)
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no"
|
||||||
|
|
||||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
echo "║ Deploying to Archipelago Target ║"
|
echo "║ Deploying to Archipelago Target ║"
|
||||||
@ -30,13 +35,40 @@ echo ""
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
QUICK=false
|
QUICK=false
|
||||||
LIVE=false
|
LIVE=false
|
||||||
|
BOTH=false
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
--quick) QUICK=true ;;
|
--quick) QUICK=true ;;
|
||||||
--live) LIVE=true ;;
|
--live) LIVE=true ;;
|
||||||
|
--both) BOTH=true ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# When --both: deploy to 228 first, then copy to 198
|
||||||
|
if [ "$BOTH" = true ]; then
|
||||||
|
echo "Deploying to both servers (228, then 198)..."
|
||||||
|
"$0" --live
|
||||||
|
echo ""
|
||||||
|
echo "📤 Copying to 192.168.1.198 (no rsync/cargo on that node)..."
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS archipelago@192.168.1.228:$TARGET_DIR/core/target/release/archipelago /tmp/archipelago-both 2>/dev/null || true
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS /tmp/archipelago-both archipelago@192.168.1.198:/tmp/archipelago-new
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS archipelago@192.168.1.228 "cd $TARGET_DIR && tar cf - web/dist/neode-ui 2>/dev/null" | sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS archipelago@192.168.1.198 "mkdir -p /tmp/web-deploy && cd /tmp/web-deploy && tar xf -"
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS archipelago@192.168.1.198 '
|
||||||
|
sudo systemctl stop archipelago
|
||||||
|
sudo cp /tmp/archipelago-new /usr/local/bin/archipelago
|
||||||
|
sudo chmod +x /usr/local/bin/archipelago
|
||||||
|
rm -f /tmp/archipelago-new
|
||||||
|
sudo rm -rf /opt/archipelago/web-ui/*
|
||||||
|
sudo cp -r /tmp/web-deploy/web/dist/neode-ui/* /opt/archipelago/web-ui/ 2>/dev/null || true
|
||||||
|
sudo chown -R 1000:1000 /opt/archipelago/web-ui
|
||||||
|
sudo systemctl start archipelago
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
echo " ✅ 192.168.1.198 deployed"
|
||||||
|
'
|
||||||
|
rm -f /tmp/archipelago-both
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Sync code
|
# Sync code
|
||||||
echo "📦 Syncing code..."
|
echo "📦 Syncing code..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" rsync -avz --delete \
|
sshpass -p "$ARCHIPELAGO_PASSWORD" rsync -avz --delete \
|
||||||
@ -88,17 +120,310 @@ if [ "$LIVE" = true ]; then
|
|||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
||||||
|
|
||||||
|
# Add /archipelago/ to nginx if missing (for peer messaging over Tor)
|
||||||
|
if [ -f "$SCRIPT_DIR/nginx-archipelago-patch.conf" ]; then
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS "$SCRIPT_DIR/nginx-archipelago-patch.conf" "$TARGET_HOST:/tmp/archipelago-nginx-patch.conf" 2>/dev/null || true
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
CFG=/etc/nginx/sites-available/archipelago
|
||||||
|
if [ -f "$CFG" ] && [ -f /tmp/archipelago-nginx-patch.conf ] && ! grep -q "location /archipelago/" "$CFG"; then
|
||||||
|
echo " Adding /archipelago/ to nginx..."
|
||||||
|
sudo sed -i "/# Proxy API requests to backend/r /tmp/archipelago-nginx-patch.conf" "$CFG"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/archipelago-nginx-patch.conf
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
echo " Restarting services..."
|
echo " Restarting services..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
||||||
|
|
||||||
# Rebuild and restart LND UI container (serves the static app at port 8081; otherwise changes to docker/lnd-ui/ are not visible)
|
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
|
||||||
echo " Rebuilding LND UI..."
|
echo " Rebuilding LND UI..."
|
||||||
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build -t lnd-ui:latest . || sudo docker build -t lnd-ui:latest .)" 2>&1 | tail -8 | sed 's/^/ /'; then
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||||
echo " Restarting LND UI container..."
|
echo " Recreating LND UI container (port 8081)..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'for c in $(sudo podman ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui) $(sudo docker ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do [ -n "$c" ] && (sudo podman restart "$c" 2>/dev/null || sudo docker restart "$c" 2>/dev/null) && break; done' || true
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
for c in $(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do
|
||||||
|
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||||
|
done
|
||||||
|
sudo $DOCKER run -d --name archy-lnd-ui -p 8081:80 --restart unless-stopped lnd-ui:latest
|
||||||
|
' 2>&1 | sed 's/^/ /' || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Rebuild and recreate Electrs UI container (port 50002)
|
||||||
|
echo " Rebuilding Electrs UI..."
|
||||||
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t electrs-ui:latest . || sudo docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||||
|
echo " Recreating Electrs UI container (port 50002, host network)..."
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
for c in $(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i electrs-ui); do
|
||||||
|
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||||
|
done
|
||||||
|
sudo $DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped electrs-ui:latest
|
||||||
|
' 2>&1 | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bitcoin Knots: required for Mempool, Electrs, BTCPay, Fedimint
|
||||||
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
|
echo " Ensuring Bitcoin Knots (required for Electrs/Mempool)..."
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
sudo \$DOCKER network create archy-net 2>/dev/null || true
|
||||||
|
NET_OPT='--network archy-net'
|
||||||
|
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then
|
||||||
|
echo ' Creating Bitcoin Knots (mainnet, archipelago RPC)...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/bitcoin
|
||||||
|
sudo \$DOCKER run -d --name bitcoin-knots --restart unless-stopped \$NET_OPT \
|
||||||
|
-p 8332:8332 -p 8333:8333 \
|
||||||
|
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||||
|
docker.io/bitcoinknots/bitcoin:latest \
|
||||||
|
-server=1 -txindex=1 \
|
||||||
|
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
|
||||||
|
-rpcuser=archipelago -rpcpassword=archipelago123 \
|
||||||
|
-dbcache=4096
|
||||||
|
echo ' Bitcoin Knots started (sync may take hours)'
|
||||||
|
else
|
||||||
|
sudo \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Fix Mempool: clean duplicates, ensure full stack - mysql, backend (8999), frontend (4080)
|
||||||
|
echo " Fixing Mempool stack (host=$TARGET_IP)..."
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
TARGET_IP='$TARGET_IP'
|
||||||
|
NET_OPT='--network archy-net'
|
||||||
|
# Clean any duplicate/old mempool containers (user may have two versions)
|
||||||
|
for c in mempool mempool-api mempool-electrs mempool-web archy-mempool-api archy-mempool-web; do
|
||||||
|
sudo \$DOCKER stop \$c 2>/dev/null
|
||||||
|
sudo \$DOCKER rm -f \$c 2>/dev/null
|
||||||
|
done
|
||||||
|
# Create mysql-mempool if missing
|
||||||
|
if ! sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'mysql-mempool|archy-mempool-db'; then
|
||||||
|
echo ' Creating mysql-mempool...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/mysql-mempool
|
||||||
|
sudo \$DOCKER run -d --name archy-mempool-db --restart unless-stopped \$NET_OPT \
|
||||||
|
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
|
||||||
|
-e MYSQL_DATABASE=mempool \
|
||||||
|
-e MYSQL_USER=mempool \
|
||||||
|
-e MYSQL_PASSWORD=mempoolpass \
|
||||||
|
-e MYSQL_ROOT_PASSWORD=rootpass \
|
||||||
|
docker.io/mariadb:10.11
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
MYSQL_CNT=\$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mempool|archy-mempool-db' | head -1)
|
||||||
|
MYSQL_CNT=\${MYSQL_CNT:-archy-mempool-db}
|
||||||
|
# Ensure DB is on archy-net so mempool-api can resolve it
|
||||||
|
sudo \$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true
|
||||||
|
# Create mempool-electrs (indexer - connects to Bitcoin, exposes Electrum protocol on 50001)
|
||||||
|
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep mempool-electrs); do
|
||||||
|
echo ' Recreating mempool-electrs...'
|
||||||
|
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 mempool-electrs; then
|
||||||
|
echo ' Creating mempool-electrs (indexer - may take hours to sync)...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/mempool-electrs
|
||||||
|
# Use host IP to reach Bitcoin (TARGET_IP:8332) - more reliable than container DNS (bitcoin-knots)
|
||||||
|
sudo \$DOCKER run -d --name mempool-electrs --restart unless-stopped \
|
||||||
|
-p 50001:50001 \
|
||||||
|
-v /var/lib/archipelago/mempool-electrs:/data \
|
||||||
|
docker.io/mempool/electrs:latest \
|
||||||
|
--daemon-rpc-addr \$TARGET_IP:8332 \
|
||||||
|
--cookie archipelago:archipelago123 \
|
||||||
|
--jsonrpc-import \
|
||||||
|
--electrum-rpc-addr 0.0.0.0:50001 \
|
||||||
|
--db-dir /data \
|
||||||
|
--lightmode
|
||||||
|
fi
|
||||||
|
# Create/recreate mempool-api (backend on 8999) - required for mempool to work
|
||||||
|
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mempool-api|archy-mempool-api'); do
|
||||||
|
echo ' Recreating mempool-api (backend)...'
|
||||||
|
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 mempool-api; then
|
||||||
|
echo ' Creating mempool-api (backend)...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/mempool
|
||||||
|
sudo \$DOCKER run -d --name mempool-api --restart unless-stopped \$NET_OPT \
|
||||||
|
-p 8999:8999 \
|
||||||
|
-v /var/lib/archipelago/mempool:/data \
|
||||||
|
-e MEMPOOL_BACKEND=electrum \
|
||||||
|
-e ELECTRUM_HOST=mempool-electrs \
|
||||||
|
-e ELECTRUM_PORT=50001 \
|
||||||
|
-e ELECTRUM_TLS_ENABLED=false \
|
||||||
|
-e CORE_RPC_HOST=\$TARGET_IP \
|
||||||
|
-e CORE_RPC_PORT=8332 \
|
||||||
|
-e CORE_RPC_USERNAME=archipelago \
|
||||||
|
-e CORE_RPC_PASSWORD=archipelago123 \
|
||||||
|
-e DATABASE_ENABLED=true \
|
||||||
|
-e DATABASE_HOST=\$MYSQL_CNT \
|
||||||
|
-e DATABASE_DATABASE=mempool \
|
||||||
|
-e DATABASE_USERNAME=mempool \
|
||||||
|
-e DATABASE_PASSWORD=mempoolpass \
|
||||||
|
docker.io/mempool/backend:v2.5.0
|
||||||
|
fi
|
||||||
|
# Recreate mempool frontend - handle both 'mempool' and 'mempool-web' (frontend was on wrong port 8999)
|
||||||
|
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^mempool\$|mempool-web|archy-mempool-web'); do
|
||||||
|
echo ' Recreating mempool frontend on 4080...'
|
||||||
|
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||||
|
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||||
|
break
|
||||||
|
done
|
||||||
|
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-mempool-web; then
|
||||||
|
echo ' Creating mempool frontend on 4080...'
|
||||||
|
sudo \$DOCKER run -d --name archy-mempool-web --restart unless-stopped \$NET_OPT \
|
||||||
|
-p 4080:8080 \
|
||||||
|
-e FRONTEND_HTTP_PORT=8080 \
|
||||||
|
-e BACKEND_MAINNET_HTTP_HOST=mempool-api \
|
||||||
|
docker.io/mempool/frontend:v2.5.0
|
||||||
|
fi
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Fix BTCPay Server: requires PostgreSQL; create archy-btcpay-db, recreate btcpay-server with BTCPAY_POSTGRES
|
||||||
|
echo " Fixing BTCPay Server stack..."
|
||||||
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
TARGET_IP='$TARGET_IP'
|
||||||
|
sudo \$DOCKER network create archy-net 2>/dev/null || true
|
||||||
|
NET_OPT='--network archy-net'
|
||||||
|
# Create PostgreSQL for BTCPay if missing
|
||||||
|
if ! sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
|
||||||
|
echo ' Creating archy-btcpay-db (PostgreSQL)...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/postgres-btcpay
|
||||||
|
sudo \$DOCKER run -d --name archy-btcpay-db --restart unless-stopped \$NET_OPT \
|
||||||
|
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
|
||||||
|
-e POSTGRES_DB=btcpay \
|
||||||
|
-e POSTGRES_USER=btcpay \
|
||||||
|
-e POSTGRES_PASSWORD=btcpaypass \
|
||||||
|
docker.io/postgres:15-alpine
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
# Recreate btcpay-server with PostgreSQL and Bitcoin RPC
|
||||||
|
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'btcpay-server|archy-btcpay'); do
|
||||||
|
echo ' Recreating btcpay-server with PostgreSQL...'
|
||||||
|
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||||
|
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||||
|
break
|
||||||
|
done
|
||||||
|
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
||||||
|
echo ' Creating btcpay-server on 23000...'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/btcpay
|
||||||
|
sudo \$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
|
||||||
|
-p 23000:49392 \
|
||||||
|
-v /var/lib/archipelago/btcpay:/datadir \
|
||||||
|
-e ASPNETCORE_URLS=http://0.0.0.0:49392 \
|
||||||
|
-e BTCPAY_PROTOCOL=http \
|
||||||
|
-e BTCPAY_HOST=\$TARGET_IP:23000 \
|
||||||
|
-e BTCPAY_CHAINS=btc \
|
||||||
|
-e BTCPAY_BTCRPCURL=http://\$TARGET_IP:8332 \
|
||||||
|
-e BTCPAY_BTCRPCUSER=archipelago \
|
||||||
|
-e BTCPAY_BTCRPCPASSWORD=archipelago123 \
|
||||||
|
-e BTCPAY_POSTGRES='User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true' \
|
||||||
|
docker.io/btcpayserver/btcpayserver:1.13.5
|
||||||
|
fi
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Tor: global hidden services - each service gets its own .onion address
|
||||||
|
echo " Setting up Tor (hidden services for each app)..."
|
||||||
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
DOCKER=podman
|
||||||
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
|
TARGET_IP='$TARGET_IP'
|
||||||
|
sudo mkdir -p /var/lib/archipelago/tor
|
||||||
|
# Deploy torrc from repo (or create if missing)
|
||||||
|
if [ -f $TARGET_DIR/scripts/tor/torrc.template ]; then
|
||||||
|
sudo cp $TARGET_DIR/scripts/tor/torrc.template /var/lib/archipelago/tor/torrc
|
||||||
|
fi
|
||||||
|
if [ ! -f /var/lib/archipelago/tor/torrc ]; then
|
||||||
|
echo 'SocksPort 9050' | sudo tee /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'ControlPort 0' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'DataDirectory /var/lib/archipelago/tor' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServiceDir /var/lib/archipelago/tor/hidden_service_archipelago/' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServicePort 80 127.0.0.1:80' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServiceDir /var/lib/archipelago/tor/hidden_service_lnd/' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServicePort 80 127.0.0.1:8081' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServiceDir /var/lib/archipelago/tor/hidden_service_btcpay/' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServicePort 80 127.0.0.1:23000' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServiceDir /var/lib/archipelago/tor/hidden_service_mempool/' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServicePort 80 127.0.0.1:4080' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServiceDir /var/lib/archipelago/tor/hidden_service_fedimint/' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
echo 'HiddenServicePort 80 127.0.0.1:8175' | sudo tee -a /var/lib/archipelago/tor/torrc
|
||||||
|
fi
|
||||||
|
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor\$'); do
|
||||||
|
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
|
||||||
|
echo ' Creating Tor container (host network for hidden services)...'
|
||||||
|
if sudo \$DOCKER run -d --name archy-tor --restart unless-stopped --network host \
|
||||||
|
-v /var/lib/archipelago/tor:/var/lib/archipelago/tor \
|
||||||
|
--entrypoint tor \
|
||||||
|
docker.io/andrius/alpine-tor:latest \
|
||||||
|
-f /var/lib/archipelago/tor/torrc 2>/dev/null; then
|
||||||
|
echo ' Tor container started (andrius/alpine-tor)'
|
||||||
|
else
|
||||||
|
echo ' Tor container image failed, trying system tor...'
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq tor 2>/dev/null || true
|
||||||
|
if command -v tor >/dev/null 2>&1; then
|
||||||
|
sudo cp /var/lib/archipelago/tor/torrc /etc/tor/torrc 2>/dev/null || true
|
||||||
|
sudo chown -R debian-tor:debian-tor /var/lib/archipelago/tor 2>/dev/null || true
|
||||||
|
sudo systemctl enable tor 2>/dev/null
|
||||||
|
sudo systemctl restart tor 2>/dev/null
|
||||||
|
echo ' Using system Tor daemon'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Tor diagnostic: check if hostname files exist (may take 30-60s after Tor starts)
|
||||||
|
echo " Checking Tor hostname files..."
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
for svc in archipelago btcpay mempool lnd fedimint; do
|
||||||
|
f=/var/lib/archipelago/tor/hidden_service_\${svc}/hostname
|
||||||
|
if [ -f \"\$f\" ]; then
|
||||||
|
echo \" ✓ \$svc: \$(cat \$f)\"
|
||||||
|
else
|
||||||
|
echo \" ✗ \$svc: hostname not yet generated (Tor may need 30-60s)\"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
|
||||||
|
echo " Fixing Fedimint API URL..."
|
||||||
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
|
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 '^fedimint\$'); do
|
||||||
|
echo ' Recreating fedimint with FM_API_URL...'
|
||||||
|
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||||
|
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||||
|
sudo \$DOCKER run -d --name fedimint --restart unless-stopped \
|
||||||
|
-p 8173:8173 -p 8174:8174 -p 8175:8175 \
|
||||||
|
-v /var/lib/archipelago/fedimint:/data \
|
||||||
|
-e FM_DATA_DIR=/data \
|
||||||
|
-e FM_BITCOIND_USERNAME=archipelago \
|
||||||
|
-e FM_BITCOIND_PASSWORD=archipelago123 \
|
||||||
|
-e FM_BITCOIN_NETWORK=bitcoin \
|
||||||
|
-e FM_BIND_P2P=0.0.0.0:8173 \
|
||||||
|
-e FM_BIND_API=0.0.0.0:8174 \
|
||||||
|
-e FM_BIND_UI=0.0.0.0:8175 \
|
||||||
|
-e FM_P2P_URL=fedimint://$TARGET_IP:8173 \
|
||||||
|
-e FM_API_URL=ws://$TARGET_IP:8174 \
|
||||||
|
-e FM_BITCOIND_URL=http://$TARGET_IP:8332 \
|
||||||
|
docker.io/fedimint/fedimintd:v0.10.0
|
||||||
|
break
|
||||||
|
done
|
||||||
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Deployed to live system!"
|
echo "✅ Deployed to live system!"
|
||||||
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
||||||
|
|||||||
6
scripts/nginx-archipelago-patch.conf
Normal file
6
scripts/nginx-archipelago-patch.conf
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
20
scripts/tor/README.md
Normal file
20
scripts/tor/README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Archipelago Tor Integration
|
||||||
|
|
||||||
|
Each service gets its own .onion address. Tor runs in a container with host networking so it can reach host-mapped ports.
|
||||||
|
|
||||||
|
## Service → Onion mapping
|
||||||
|
|
||||||
|
| Service | LAN Port | Tor Hidden Service Dir |
|
||||||
|
|-----------|----------|-------------------------------|
|
||||||
|
| Archipelago | 80 | hidden_service_archipelago |
|
||||||
|
| LND UI | 8081 | hidden_service_lnd |
|
||||||
|
| BTCPay | 23000 | hidden_service_btcpay |
|
||||||
|
| Mempool | 4080 | hidden_service_mempool |
|
||||||
|
| Fedimint | 8175 | hidden_service_fedimint |
|
||||||
|
|
||||||
|
## Hostname files
|
||||||
|
|
||||||
|
After Tor starts, each service's .onion address is written to:
|
||||||
|
`/var/lib/archipelago/tor/hidden_service_<name>/hostname`
|
||||||
|
|
||||||
|
The backend reads these to expose Tor addresses in the package API.
|
||||||
29
scripts/tor/torrc.template
Normal file
29
scripts/tor/torrc.template
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Archipelago Tor Hidden Services
|
||||||
|
# Each service gets its own .onion address
|
||||||
|
# Tor runs with --network host so 127.0.0.1 refers to host ports
|
||||||
|
# DataDirectory: use /var/lib/archipelago/tor so backend can read hostnames
|
||||||
|
# SocksPort 9050: required for outbound .onion requests (peer messaging)
|
||||||
|
|
||||||
|
SocksPort 9050
|
||||||
|
ControlPort 0
|
||||||
|
DataDirectory /var/lib/archipelago/tor
|
||||||
|
|
||||||
|
# Archipelago main web UI (nginx port 80)
|
||||||
|
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_archipelago/
|
||||||
|
HiddenServicePort 80 127.0.0.1:80
|
||||||
|
|
||||||
|
# LND UI
|
||||||
|
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_lnd/
|
||||||
|
HiddenServicePort 80 127.0.0.1:8081
|
||||||
|
|
||||||
|
# BTCPay Server
|
||||||
|
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_btcpay/
|
||||||
|
HiddenServicePort 80 127.0.0.1:23000
|
||||||
|
|
||||||
|
# Mempool (frontend)
|
||||||
|
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_mempool/
|
||||||
|
HiddenServicePort 80 127.0.0.1:4080
|
||||||
|
|
||||||
|
# Fedimint Guardian UI
|
||||||
|
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_fedimint/
|
||||||
|
HiddenServicePort 80 127.0.0.1:8175
|
||||||
@ -127,7 +127,7 @@ echo " • Lightning (LND) UI: http://localhost:8085"
|
|||||||
echo " • Lightning REST API: http://localhost:8080"
|
echo " • Lightning REST API: http://localhost:8080"
|
||||||
echo " • BTCPay Server: http://localhost:14142"
|
echo " • BTCPay Server: http://localhost:14142"
|
||||||
echo " • Mempool Explorer: http://localhost:4080"
|
echo " • Mempool Explorer: http://localhost:4080"
|
||||||
echo " • Fedimint: http://localhost:8173"
|
echo " • Fedimint (Guardian UI): http://localhost:8175"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 🏠 Self-Hosted Services:"
|
echo " 🏠 Self-Hosted Services:"
|
||||||
echo " • Home Assistant: http://localhost:8123"
|
echo " • Home Assistant: http://localhost:8123"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user