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
|
||||
|
||||
## Priority: Deploy-Test-Fix Loop
|
||||
|
||||
**This is the primary workflow. Follow it for every change.**
|
||||
|
||||
1. **Make the change** the user requests
|
||||
2. **SSH and build to live server** - Run `./scripts/deploy-to-target.sh --live` once done
|
||||
3. **Test that it works** - Verify apps launch and show their UI in a new browser tab on their server port (e.g. http://192.168.1.228:4080 for Mempool)
|
||||
4. **If broken, fix and repeat** - Debug, fix, redeploy, and test again until complete
|
||||
5. **End loop** only when everything works
|
||||
|
||||
Do not leave deployment or testing to the user. The agent has SSH access to perform all building and work on the live server.
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
**Always deploy to live system for testing** - The target device (192.168.1.228) is a development machine, so deploy changes directly to the live system rather than using dev servers.
|
||||
|
||||
**When making changes, always run deploy** - After editing code (frontend, backend, scripts, or configs), run `./scripts/deploy-to-target.sh --live` to sync, build, and deploy. Do not leave the user to deploy manually.
|
||||
|
||||
### Backend: build on server via rsync (never on macOS)
|
||||
- **Always** deploy backend by: (1) rsync `core/` to `archipelago@192.168.1.228:~/archy/core/`, then (2) SSH and run `cargo build --release` on the server, then copy binary to `/usr/local/bin/` and restart `archipelago.service`.
|
||||
- Use `sshpass -p "archipelago"` for non-interactive rsync/SSH. **Action these builds** when making backend changes; do not build the Rust binary on macOS and copy it (causes Exec format error on Linux).
|
||||
@ -27,9 +41,17 @@ This command:
|
||||
- Backend: `/usr/local/bin/archipelago`
|
||||
4. Restarts services (systemd + nginx)
|
||||
|
||||
### Deploy to Both Servers
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --both
|
||||
```
|
||||
|
||||
Deploys to 192.168.1.228 first (builds there), then copies binary and web-ui to 192.168.1.198 (which has no rsync/cargo).
|
||||
|
||||
### Target Environment
|
||||
|
||||
- **Host**: archipelago@192.168.1.228
|
||||
- **Host**: archipelago@192.168.1.228 (primary), archipelago@192.168.1.198 (secondary)
|
||||
- **OS**: Debian-based server
|
||||
- **Container Runtime**: Podman (root context for system services)
|
||||
- **Web Server**: Nginx
|
||||
@ -60,12 +82,33 @@ The deployment scripts require SSH key authentication. If you encounter `Permiss
|
||||
- Systemd service: `/etc/systemd/system/archipelago.service`
|
||||
- Nginx config: `/etc/nginx/sites-available/archipelago`
|
||||
|
||||
## App Icons
|
||||
|
||||
**Single source of truth**: `neode-ui/public/assets/img/app-icons/`
|
||||
|
||||
- All app icons live here. Do not duplicate icons elsewhere.
|
||||
- Naming: `{app-id}.{png|webp|svg}` (e.g. `fedimint.png`, `mempool.webp`)
|
||||
- References use `/assets/img/app-icons/{filename}`. Build outputs copy from this folder.
|
||||
- See `neode-ui/public/assets/img/app-icons/README.md` for details.
|
||||
|
||||
## App Integration Standards
|
||||
|
||||
**When adding or fixing apps, always verify end-to-end:**
|
||||
|
||||
1. **Test the app UI on its port** - After getting an app working, confirm the web UI loads at its configured port (e.g. `http://192.168.1.228:4080` for Mempool).
|
||||
2. **Auto-connect dependencies** - Apps must connect to their dependencies on installation:
|
||||
- **Bitcoin node**: LND, Fedimint, BTCPay Server, Mempool all need Bitcoin RPC (host.containers.internal:8332 or bitcoin-knots container).
|
||||
- **LND**: BTCPay Server and other Lightning apps need LND connection.
|
||||
3. **Works out of the box** - After autoinstaller flash, apps should work without manual configuration. Ensure `get_app_config()` in `core/archipelago/src/api/rpc.rs` has correct env vars for each app.
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
1. Make changes locally
|
||||
2. Deploy with `--live` flag
|
||||
3. Test at http://192.168.1.228
|
||||
4. Check logs if needed:
|
||||
4. **Verify each modified app**: Open its UI URL and confirm it loads and connects to dependencies
|
||||
5. **Test with Cursor browser MCP** (when available): After app installs or fixes, use the browser MCP to open the app URL, check for console errors (502, WebSocket failures, etc.), debug, fix, redeploy, and repeat until working.
|
||||
6. Check logs if needed:
|
||||
- Backend: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f'`
|
||||
- Nginx: `ssh archipelago@192.168.1.228 'sudo tail -f /var/log/nginx/error.log'`
|
||||
5. **Sync changes back to ISO build** (see below)
|
||||
@ -82,6 +125,47 @@ Common containers:
|
||||
- Bitcoin Knots (ports 8332, 8333)
|
||||
- LND (ports 9735, 10009)
|
||||
|
||||
## ISO Build Debug Workflow (Flash-and-Debug)
|
||||
|
||||
**Primary way to improve ISO builds.** After flashing a new machine from the ISO, SSH in and diagnose. Fix issues in the build, rebuild ISO, reflash, repeat.
|
||||
|
||||
### Debug a Fresh ISO Install
|
||||
|
||||
1. **Flash** the ISO to a test machine (e.g. 192.168.1.198)
|
||||
2. **SSH** after first boot (same user/password as dev: `archipelago`/`archipelago`):
|
||||
```bash
|
||||
ssh-keygen -R 192.168.1.198 # if host key changed after reflash
|
||||
sshpass -p "archipelago" ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198
|
||||
```
|
||||
3. **Run diagnostics** to find issues:
|
||||
```bash
|
||||
# Services
|
||||
systemctl is-active archipelago nginx
|
||||
# Containers
|
||||
sudo podman ps -a
|
||||
# Tor hostname (backend needs this for peer discovery)
|
||||
sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname
|
||||
sudo -u archipelago cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname 2>&1 # should NOT be "Permission denied"
|
||||
# Backend logs
|
||||
sudo journalctl -u archipelago -n 50
|
||||
# Nginx errors
|
||||
sudo tail -20 /var/log/nginx/error.log
|
||||
# RPC reachable?
|
||||
curl -s -X POST http://127.0.0.1:5678/rpc/v1 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{}}'
|
||||
```
|
||||
4. **Fix** issues in `image-recipe/build-auto-installer-iso.sh`, scripts, or configs
|
||||
5. **Rebuild** ISO, **reflash**, **re-diagnose** until clean
|
||||
|
||||
### Common ISO Issues to Check
|
||||
|
||||
| Issue | Check | Fix |
|
||||
|-------|-------|-----|
|
||||
| Tor hostname unreadable | `sudo -u archipelago cat .../hostname` | setup-tor.sh must chmod 711 on tor dir + hidden_service_* dirs, 644 on hostname files |
|
||||
| Node not discoverable | Tor hostname + Nostr publish | Fix Tor perms so node_address is set |
|
||||
| RPC timeouts | nginx error.log | Increase proxy timeouts or optimize slow RPCs |
|
||||
| Missing containers | `sudo podman ps -a` | ISO is minimal; apps install from marketplace |
|
||||
| bitcoin-ui 404 | Port 8334 not listening | Add bitcoin-ui to first-boot or document |
|
||||
|
||||
## ISO Build Integration
|
||||
|
||||
**CRITICAL**: After testing on the live server, always update the ISO build to include your changes.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,6 +30,7 @@ build/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
scripts/deploy-config.sh
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: fedimint
|
||||
name: Fedimint
|
||||
version: 0.3.0
|
||||
description: Federated Bitcoin minting service. Privacy-preserving Bitcoin custody.
|
||||
version: 0.10.0
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
container:
|
||||
image: fedimint/fedimint:0.3.0
|
||||
image: fedimint/fedimintd:v0.10.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
@ -28,10 +28,13 @@ app:
|
||||
ports:
|
||||
- host: 8173
|
||||
container: 8173
|
||||
protocol: tcp # API
|
||||
protocol: tcp # P2P
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp # Web UI
|
||||
protocol: tcp # API
|
||||
- host: 8175
|
||||
container: 8175
|
||||
protocol: tcp # Built-in Guardian UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@ -40,15 +43,17 @@ app:
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- FM_BITCOIND_RPC=http://bitcoin-core:8332
|
||||
- FM_BITCOIND_RPC_USER=${BITCOIN_RPC_USER}
|
||||
- FM_BITCOIND_RPC_PASS=${BITCOIN_RPC_PASSWORD}
|
||||
- FM_NETWORK=mainnet
|
||||
- FM_DATA_DIR=/fedimint
|
||||
- FM_BITCOIND_URL=http://bitcoin-core:8332
|
||||
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
|
||||
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_UI=0.0.0.0:8175
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8174
|
||||
path: /health
|
||||
endpoint: http://localhost:8175
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
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"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
|
||||
# Node identity (Ed25519)
|
||||
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for LND REST proxy)
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
|
||||
reqwest = { version = "0.11", features = ["json", "socks"] }
|
||||
|
||||
# Nostr (node discovery)
|
||||
nostr-sdk = "0.44"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::electrs_status;
|
||||
use crate::node_message as node_msg;
|
||||
use crate::config::Config;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
@ -20,7 +22,7 @@ pub struct ApiHandler {
|
||||
|
||||
impl ApiHandler {
|
||||
pub async fn new(config: Config, state_manager: Arc<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 {
|
||||
_config: config,
|
||||
@ -45,7 +47,7 @@ impl ApiHandler {
|
||||
let (parts, body) = req.into_parts();
|
||||
let body_bytes = hyper::body::to_bytes(body).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes));
|
||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||
|
||||
debug!("{} {}", method, path);
|
||||
|
||||
@ -55,6 +57,10 @@ impl ApiHandler {
|
||||
.status(StatusCode::OK)
|
||||
.body(hyper::Body::from("OK"))
|
||||
.unwrap()),
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
}
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
(Method::GET, path) if path.starts_with("/api/container/logs") => {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path).await
|
||||
}
|
||||
@ -116,6 +122,39 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_node_message(body: hyper::body::Bytes) -> Result<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>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::container::docker_packages;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::identity;
|
||||
use crate::node_message;
|
||||
use crate::nostr_discovery;
|
||||
use crate::peers::{self, KnownPeer};
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -33,10 +40,12 @@ pub struct RpcHandler {
|
||||
config: Config,
|
||||
auth_manager: AuthManager,
|
||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
state_manager: Arc<StateManager>,
|
||||
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||
}
|
||||
|
||||
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 orchestrator = if config.dev_mode {
|
||||
Some(Arc::new(
|
||||
@ -45,11 +54,14 @@ impl RpcHandler {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_manager,
|
||||
orchestrator,
|
||||
state_manager,
|
||||
port_allocator,
|
||||
})
|
||||
}
|
||||
|
||||
@ -73,6 +85,9 @@ impl RpcHandler {
|
||||
"server.echo" => self.handle_echo(rpc_req.params).await,
|
||||
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(rpc_req.params).await,
|
||||
@ -89,11 +104,26 @@ impl RpcHandler {
|
||||
"package.start" => self.handle_package_start(rpc_req.params).await,
|
||||
"package.stop" => self.handle_package_stop(rpc_req.params).await,
|
||||
"package.restart" => self.handle_package_restart(rpc_req.params).await,
|
||||
"package.uninstall" => self.handle_package_uninstall(rpc_req.params).await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
|
||||
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
|
||||
|
||||
// Node identity and P2P peers
|
||||
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
|
||||
"node-list-peers" => self.handle_node_list_peers().await,
|
||||
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
|
||||
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
|
||||
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
|
||||
"node-messages-received" => self.handle_node_messages_received().await,
|
||||
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||
"node.did" => self.handle_node_did().await,
|
||||
"node.tor-address" => self.handle_node_tor_address().await,
|
||||
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||
|
||||
_ => {
|
||||
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||
}
|
||||
@ -174,6 +204,103 @@ impl RpcHandler {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
async fn handle_auth_change_password(
|
||||
&self,
|
||||
params: Option<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(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@ -525,10 +652,20 @@ impl RpcHandler {
|
||||
];
|
||||
|
||||
// App-specific configuration (should come from manifest)
|
||||
let (ports, volumes, env_vars, custom_command) = get_app_config(package_id);
|
||||
let (ports, volumes, env_vars, custom_command, custom_args) = {
|
||||
let mut allocator = self.port_allocator.lock().map_err(|e| {
|
||||
anyhow::anyhow!("Port allocator lock poisoned: {}", e)
|
||||
})?;
|
||||
get_app_config(package_id, &self.config.host_ip, &mut allocator)
|
||||
};
|
||||
|
||||
// Special handling for Tailscale: requires host network and privileged mode
|
||||
// Special handling: Tailscale needs host network; mempool stack needs archy-net
|
||||
let is_tailscale = package_id == "tailscale";
|
||||
let needs_archy_net = matches!(
|
||||
package_id,
|
||||
"mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
||||
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db"
|
||||
);
|
||||
|
||||
if is_tailscale {
|
||||
run_args.push("--network=host");
|
||||
@ -536,6 +673,13 @@ impl RpcHandler {
|
||||
run_args.push("--cap-add=NET_ADMIN");
|
||||
run_args.push("--cap-add=NET_RAW");
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
} else if needs_archy_net {
|
||||
// Ensure archy-net exists, then attach
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "network", "create", "archy-net"])
|
||||
.output()
|
||||
.await;
|
||||
run_args.push("--network=archy-net");
|
||||
}
|
||||
|
||||
// Create data directories if they don't exist
|
||||
@ -591,9 +735,11 @@ impl RpcHandler {
|
||||
let mut cmd = tokio::process::Command::new("sudo");
|
||||
cmd.args(&run_args);
|
||||
|
||||
// Add custom command if specified (e.g., for Tailscale web UI)
|
||||
// Add custom command/args if specified (Tailscale: shell override; electrs: CLI args)
|
||||
if let Some(custom_cmd) = custom_command {
|
||||
cmd.arg(custom_cmd);
|
||||
} else if let Some(args) = custom_args {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let run_output = cmd
|
||||
@ -627,35 +773,22 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
|
||||
// But also check if container exists without the prefix
|
||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
debug!("Found container without prefix: {}", package_id);
|
||||
package_id.to_string()
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
let to_start: Vec<String> = if containers.is_empty() {
|
||||
vec![format!("archy-{}", package_id)]
|
||||
} else {
|
||||
debug!("Using archy- prefix: archy-{}", package_id);
|
||||
format!("archy-{}", package_id)
|
||||
}
|
||||
} else {
|
||||
format!("archy-{}", package_id)
|
||||
// Start order for mempool: db first, then api, then web
|
||||
let order = ["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"];
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99));
|
||||
sorted
|
||||
};
|
||||
|
||||
// Use podman CLI to start the container
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", &container_name])
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", &name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman start")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
@ -671,34 +804,22 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name
|
||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
debug!("Found container without prefix: {}", package_id);
|
||||
package_id.to_string()
|
||||
} else {
|
||||
debug!("Using archy- prefix: archy-{}", package_id);
|
||||
format!("archy-{}", package_id)
|
||||
}
|
||||
} else {
|
||||
format!("archy-{}", package_id)
|
||||
};
|
||||
|
||||
// Use podman CLI to stop the container
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
// Fallback: try single container
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", &container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman stop")?;
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
@ -714,39 +835,74 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name
|
||||
let container_name = if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.trim().is_empty() {
|
||||
debug!("Found container without prefix: {}", package_id);
|
||||
package_id.to_string()
|
||||
} else {
|
||||
debug!("Using archy- prefix: archy-{}", package_id);
|
||||
format!("archy-{}", package_id)
|
||||
}
|
||||
} else {
|
||||
format!("archy-{}", package_id)
|
||||
};
|
||||
|
||||
// Use podman CLI to restart the container
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", &container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman restart")?;
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to restart container: {}", stderr));
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Uninstall a package: stop and remove all related containers, clean data. No fragments left.
|
||||
async fn handle_package_uninstall(
|
||||
&self,
|
||||
params: Option<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)
|
||||
async fn handle_bundled_app_start(
|
||||
&self,
|
||||
@ -858,6 +1014,150 @@ impl RpcHandler {
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||
}
|
||||
|
||||
async fn handle_node_add_peer(
|
||||
&self,
|
||||
params: Option<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
|
||||
@ -886,106 +1186,204 @@ fn is_valid_docker_image(image: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Get app-specific configuration
|
||||
/// Returns: (ports, volumes, env_vars, custom_command)
|
||||
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
|
||||
/// custom_command: shell override (e.g. "sh -c '...'"); custom_args: extra args for entrypoint
|
||||
/// Uses port_allocator for apps with web UIs to avoid conflicts (e.g. Nextcloud vs LND UI).
|
||||
/// TODO: Load from manifest.yml files in apps/ directory
|
||||
fn get_app_config(app_id: &str) -> (Vec<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 {
|
||||
"homeassistant" | "home-assistant" => (
|
||||
vec!["8123:8123".to_string()],
|
||||
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"bitcoin" | "bitcoin-core" => (
|
||||
vec!["8332:8332".to_string(), "8333:8333".to_string()],
|
||||
vec!["/var/lib/archipelago/bitcoin:/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"lnd" => (
|
||||
vec!["9735:9735".to_string(), "10009:10009".to_string(), "8080:8080".to_string()],
|
||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||
vec!["BITCOIN_ACTIVE=1".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"btcpay-server" | "btcpayserver" => (
|
||||
vec!["23000:49392".to_string()],
|
||||
vec!["/var/lib/archipelago/btcpay:/datadir".to_string()],
|
||||
vec![],
|
||||
vec![
|
||||
"ASPNETCORE_URLS=http://0.0.0.0:49392".to_string(),
|
||||
"BTCPAY_PROTOCOL=http".to_string(),
|
||||
format!("BTCPAY_HOST={}:23000", host_ip),
|
||||
"BTCPAY_CHAINS=btc".to_string(),
|
||||
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
||||
"BTCPAY_BTCRPCUSER=archipelago".to_string(),
|
||||
"BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(),
|
||||
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool" => (
|
||||
vec!["8999:8080".to_string()],
|
||||
"mempool" | "mempool-web" => (
|
||||
vec!["4080:8080".to_string()],
|
||||
vec![],
|
||||
// Frontend proxies to backend at host:8999 (deploy script uses mempool-api when on archy-net)
|
||||
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=mempool-electrs".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=bitcoin".to_string(),
|
||||
"CORE_RPC_PASSWORD=bitcoinpass".to_string(),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
"DATABASE_PASSWORD=mempoolpass".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-electrs" => (
|
||||
vec!["50001:50001".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
Some(vec![
|
||||
"--daemon-rpc-addr".to_string(),
|
||||
format!("{}:8332", host_ip),
|
||||
"--cookie".to_string(),
|
||||
"bitcoin:bitcoinpass".to_string(),
|
||||
"--jsonrpc-import".to_string(),
|
||||
"--electrum-rpc-addr".to_string(),
|
||||
"0.0.0.0:50001".to_string(),
|
||||
"--db-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--lightmode".to_string(),
|
||||
]),
|
||||
),
|
||||
"mysql-mempool" => (
|
||||
vec![],
|
||||
vec!["/var/lib/archipelago/mysql-mempool:/var/lib/mysql".to_string()],
|
||||
vec![
|
||||
"MYSQL_DATABASE=mempool".to_string(),
|
||||
"MYSQL_USER=mempool".to_string(),
|
||||
"MYSQL_PASSWORD=mempoolpass".to_string(),
|
||||
"MYSQL_ROOT_PASSWORD=rootpass".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"grafana" => (
|
||||
vec!["3000:3000".to_string()],
|
||||
vec!["/var/lib/archipelago/grafana:/var/lib/grafana".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"searxng" => (
|
||||
vec!["8888:8080".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"ollama" => (
|
||||
vec!["11434:11434".to_string()],
|
||||
vec!["/var/lib/archipelago/ollama:/root/.ollama".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"onlyoffice" | "onlyoffice-documentserver" => (
|
||||
vec!["9980:80".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"penpot" | "penpot-frontend" => (
|
||||
vec!["9001:80".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nextcloud" => (
|
||||
vec!["8081:80".to_string()],
|
||||
"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,
|
||||
),
|
||||
"vaultwarden" => (
|
||||
vec!["8082:80".to_string()],
|
||||
None,
|
||||
)
|
||||
}
|
||||
"vaultwarden" => {
|
||||
let host_port = allocator
|
||||
.allocate_or_get(app_id, 8082, 80)
|
||||
.unwrap_or(8082);
|
||||
(
|
||||
vec![format!("{}:80", host_port)],
|
||||
vec!["/var/lib/archipelago/vaultwarden:/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
"jellyfin" => (
|
||||
vec!["8096:8096".to_string()],
|
||||
vec!["/var/lib/archipelago/jellyfin/config:/config".to_string(), "/var/lib/archipelago/jellyfin/cache:/cache".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"photoprism" => (
|
||||
vec!["2342:2342".to_string()],
|
||||
vec!["/var/lib/archipelago/photoprism:/photoprism/storage".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"immich" => (
|
||||
vec!["2283:3001".to_string()],
|
||||
vec!["/var/lib/archipelago/immich:/usr/src/app/upload".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"filebrowser" => (
|
||||
vec!["8083:80".to_string()],
|
||||
"filebrowser" => {
|
||||
let host_port = allocator
|
||||
.allocate_or_get(app_id, 8083, 80)
|
||||
.unwrap_or(8083);
|
||||
(
|
||||
vec![format!("{}:80", host_port)],
|
||||
vec!["/var/lib/archipelago/filebrowser:/srv".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
vec!["81:81".to_string(), "8084:80".to_string(), "8443:443".to_string()],
|
||||
vec![
|
||||
@ -994,18 +1392,21 @@ fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Optio
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"portainer" => (
|
||||
vec!["9000:9000".to_string()],
|
||||
vec!["/var/lib/archipelago/portainer:/data".to_string(), "/var/run/podman/podman.sock:/var/run/docker.sock".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"uptime-kuma" => (
|
||||
vec!["3001:3001".to_string()],
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()], // Tailscale web UI port (only used if not host network)
|
||||
@ -1016,18 +1417,30 @@ fn get_app_config(app_id: &str) -> (Vec<String>, Vec<String>, Vec<String>, Optio
|
||||
"TS_STATE_DIR=/var/lib/tailscale".to_string(),
|
||||
],
|
||||
Some("sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string()),
|
||||
),
|
||||
"fedimint" => (
|
||||
vec!["8173:8173".to_string()],
|
||||
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
|
||||
vec![
|
||||
"FM_BITCOIN_RPC_KIND=bitcoind".to_string(),
|
||||
"FM_BITCOIN_RPC_URL=http://host.containers.internal:8332".to_string(),
|
||||
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
|
||||
"FM_BIND_API=0.0.0.0:8174".to_string(),
|
||||
],
|
||||
None,
|
||||
),
|
||||
_ => (vec![], vec![], vec![], None), // No default config, user must configure manually
|
||||
"fedimint" => (
|
||||
vec![
|
||||
"8173:8173".to_string(), // P2P
|
||||
"8174:8174".to_string(), // API (JSON-RPC)
|
||||
"8175:8175".to_string(), // Built-in Guardian UI
|
||||
],
|
||||
vec!["/var/lib/archipelago/fedimint:/data".to_string()],
|
||||
vec![
|
||||
"FM_DATA_DIR=/data".to_string(),
|
||||
"FM_BITCOIND_USERNAME=bitcoin".to_string(),
|
||||
"FM_BITCOIND_PASSWORD=bitcoinpass".to_string(),
|
||||
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
|
||||
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
|
||||
"FM_BIND_API=0.0.0.0:8174".to_string(),
|
||||
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
||||
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
||||
format!("FM_API_URL=ws://{}:8174", host_ip),
|
||||
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
_ => (vec![], vec![], vec![], None, None), // No default config, user must configure manually
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OnboardingState {
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
@ -46,10 +51,13 @@ impl AuthManager {
|
||||
|
||||
let password_hash = hash(password, DEFAULT_COST)?;
|
||||
|
||||
// If onboarding was already completed (before setup), preserve that
|
||||
let onboarding_complete = self.is_onboarding_complete().await?;
|
||||
|
||||
let user = User {
|
||||
password_hash,
|
||||
setup_complete: true,
|
||||
onboarding_complete: false,
|
||||
onboarding_complete,
|
||||
};
|
||||
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
@ -60,6 +68,15 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
pub async fn complete_onboarding(&self) -> Result<()> {
|
||||
// Persist to onboarding.json (works even before user/setup exists)
|
||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||
let state = OnboardingState { complete: true };
|
||||
fs::write(
|
||||
&onboarding_file,
|
||||
serde_json::to_string_pretty(&state)?,
|
||||
)
|
||||
.await?;
|
||||
// Also update user.json if it exists (keeps them in sync)
|
||||
if let Some(mut user) = self.get_user().await? {
|
||||
user.onboarding_complete = true;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
@ -69,6 +86,25 @@ impl AuthManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_onboarding_complete(&self) -> Result<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> {
|
||||
use bcrypt::verify;
|
||||
|
||||
@ -78,4 +114,113 @@ impl AuthManager {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Change password: verify current, validate new, update user.json and optionally SSH.
|
||||
/// New password must be 12+ chars with upper, lower, digit, and special character.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
also_change_ssh: bool,
|
||||
) -> Result<()> {
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
|
||||
if !self.verify_password(current_password).await? {
|
||||
anyhow::bail!("Current password is incorrect");
|
||||
}
|
||||
|
||||
validate_password_strength(new_password)?;
|
||||
|
||||
let password_hash = hash(new_password, DEFAULT_COST)?;
|
||||
|
||||
let mut user = self
|
||||
.get_user()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||
|
||||
user.password_hash = password_hash;
|
||||
let user_file = self.data_dir.join("user.json");
|
||||
let content = serde_json::to_string_pretty(&user)?;
|
||||
fs::write(&user_file, content).await?;
|
||||
|
||||
if also_change_ssh {
|
||||
change_ssh_password(new_password).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate password strength: 12+ chars, upper, lower, digit, special.
|
||||
fn validate_password_strength(password: &str) -> Result<()> {
|
||||
if password.len() < 12 {
|
||||
anyhow::bail!("Password must be at least 12 characters");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
anyhow::bail!("Password must contain at least one uppercase letter");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
anyhow::bail!("Password must contain at least one lowercase letter");
|
||||
}
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
anyhow::bail!("Password must contain at least one digit");
|
||||
}
|
||||
if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
|
||||
anyhow::bail!("Password must contain at least one special character (!@#$%^&* etc.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change the archipelago user's SSH/login password.
|
||||
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
||||
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
||||
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
|
||||
// Use /usr/bin/openssl - systemd services often have minimal PATH
|
||||
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
|
||||
.args(["passwd", "-6", "-stdin"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
|
||||
|
||||
{
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stdin = hash_child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
|
||||
stdin.write_all(new_password.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
|
||||
let hash_result = hash_child.wait_with_output().await?;
|
||||
if !hash_result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&hash_result.stderr);
|
||||
anyhow::bail!("openssl passwd failed: {}", stderr);
|
||||
}
|
||||
let hash = String::from_utf8(hash_result.stdout)?
|
||||
.trim()
|
||||
.to_string();
|
||||
if hash.is_empty() {
|
||||
anyhow::bail!("openssl passwd produced empty hash");
|
||||
}
|
||||
|
||||
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
||||
// Use /usr/sbin/usermod - not always in systemd's PATH
|
||||
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
||||
.args(["-p", &hash, &ssh_user])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
anyhow::bail!("usermod failed: {}", stderr);
|
||||
}
|
||||
|
||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -45,15 +45,40 @@ pub struct Config {
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub log_level: String,
|
||||
/// Host IP for container env vars (FM_API_URL, BACKEND_MAINNET_HTTP_HOST, etc.)
|
||||
pub host_ip: String,
|
||||
// Dev mode configuration
|
||||
pub dev_mode: bool,
|
||||
pub container_runtime: ContainerRuntime,
|
||||
pub port_offset: u16,
|
||||
pub bitcoin_simulation: BitcoinSimulation,
|
||||
pub dev_data_dir: PathBuf,
|
||||
/// Nostr discovery: opt-in only. When true + relays non-empty, publish node to relays.
|
||||
#[serde(default)]
|
||||
pub nostr_discovery_enabled: bool,
|
||||
/// Nostr relay URLs (comma-separated). Only used when nostr_discovery_enabled.
|
||||
#[serde(default)]
|
||||
pub nostr_relays: Vec<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 {
|
||||
/// 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> {
|
||||
// Default configuration
|
||||
let mut config = Self::default();
|
||||
@ -124,6 +149,29 @@ impl Config {
|
||||
config.dev_data_dir = PathBuf::from(dev_data_dir);
|
||||
}
|
||||
|
||||
// Nostr discovery (opt-in, secure by default)
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED") {
|
||||
config.nostr_discovery_enabled = v.parse().unwrap_or(false);
|
||||
}
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_RELAYS") {
|
||||
config.nostr_relays = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY") {
|
||||
let s = v.trim().to_string();
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// Host IP for container env vars (detect if not set)
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
} else {
|
||||
config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
fs::create_dir_all(&config.data_dir).await
|
||||
.context("Failed to create data directory")?;
|
||||
@ -145,11 +193,18 @@ impl Default for Config {
|
||||
bind_host: "0.0.0.0".to_string(),
|
||||
bind_port: 5678,
|
||||
log_level: "info".to_string(),
|
||||
host_ip: "127.0.0.1".to_string(),
|
||||
dev_mode: false,
|
||||
container_runtime: ContainerRuntime::Auto,
|
||||
port_offset: 10000,
|
||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
||||
nostr_discovery_enabled: true,
|
||||
nostr_relays: vec![
|
||||
"wss://relay.damus.io".into(),
|
||||
"wss://relay.nostr.info".into(),
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,11 +53,15 @@ impl DockerPackageScanner {
|
||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||
for container in &containers {
|
||||
if container.name.ends_with("-ui") {
|
||||
// Map bitcoin-ui -> bitcoin, lnd-ui -> lnd
|
||||
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
||||
let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name);
|
||||
let canonical_id = parent_app
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(parent_app)
|
||||
.to_string();
|
||||
if !container.ports.is_empty() {
|
||||
if let Some(ui_address) = extract_lan_address(&container.ports) {
|
||||
ui_containers.insert(parent_app.to_string(), ui_address);
|
||||
ui_containers.insert(canonical_id, ui_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -109,6 +113,14 @@ impl DockerPackageScanner {
|
||||
// But web UI is always on port 8240
|
||||
debug!("Tailscale detected, using port 8240");
|
||||
Some("http://localhost:8240".to_string())
|
||||
} else if app_id == "fedimint" {
|
||||
// Fedimint built-in Guardian UI on port 8175
|
||||
debug!("Using fedimint built-in Guardian UI: http://localhost:8175");
|
||||
Some("http://localhost:8175".to_string())
|
||||
} else if app_id == "mempool-electrs" || app_id == "electrs" {
|
||||
// Electrs UI runs on host at port 50002
|
||||
debug!("Using electrs-ui for mempool-electrs: http://localhost:50002");
|
||||
Some("http://localhost:50002".to_string())
|
||||
} else {
|
||||
// Extract port from the main container
|
||||
extract_lan_address(&container.ports)
|
||||
@ -119,6 +131,8 @@ impl DockerPackageScanner {
|
||||
// Convert container state to package/service state
|
||||
let (package_state, service_status) = convert_state(&container.state);
|
||||
|
||||
let tor_address = read_tor_address(&app_id);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
static_files: StaticFiles {
|
||||
@ -143,11 +157,11 @@ impl DockerPackageScanner {
|
||||
donation_url: None,
|
||||
author: Some("Archipelago".to_string()),
|
||||
website: lan_address.clone(),
|
||||
interfaces: if lan_address.is_some() {
|
||||
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||
Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: Some("true".to_string()),
|
||||
tor_config: None,
|
||||
tor_config: tor_address.clone(),
|
||||
lan_config: None,
|
||||
}),
|
||||
})
|
||||
@ -159,13 +173,17 @@ impl DockerPackageScanner {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
last_backup: None,
|
||||
interface_addresses: if let Some(addr) = lan_address {
|
||||
interface_addresses: if lan_address.is_some() || tor_address.is_some() {
|
||||
let mut addresses = HashMap::new();
|
||||
// Only include tor_address if we have a real v3 .onion (not placeholder)
|
||||
let tor = tor_address
|
||||
.filter(|s| is_real_onion_address(s))
|
||||
.unwrap_or_default();
|
||||
addresses.insert(
|
||||
"main".to_string(),
|
||||
InterfaceAddress {
|
||||
tor_address: format!("{}.onion", app_id),
|
||||
lan_address: Some(addr),
|
||||
tor_address: tor,
|
||||
lan_address: lan_address,
|
||||
},
|
||||
);
|
||||
addresses
|
||||
@ -227,7 +245,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
"fedimint" => AppMetadata {
|
||||
title: "Fedimint".to_string(),
|
||||
description: "Federated Bitcoin mint".to_string(),
|
||||
icon: "/assets/img/icon-fedimint.jpeg".to_string(),
|
||||
icon: "/assets/img/app-icons/fedimint.png".to_string(),
|
||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
@ -248,6 +266,12 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
icon: "/assets/img/app-icons/mempool.webp".to_string(),
|
||||
repo: "https://github.com/mempool/mempool".to_string(),
|
||||
},
|
||||
"mempool-electrs" | "electrs" => AppMetadata {
|
||||
title: "Electrs".to_string(),
|
||||
description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(),
|
||||
icon: "/assets/img/app-icons/electrs.svg".to_string(),
|
||||
repo: "https://github.com/romanz/electrs".to_string(),
|
||||
},
|
||||
"ollama" => AppMetadata {
|
||||
title: "Ollama".to_string(),
|
||||
description: "Run large language models locally".to_string(),
|
||||
@ -347,6 +371,40 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
fn tor_service_name(app_id: &str) -> Option<&'static str> {
|
||||
match app_id {
|
||||
"archipelago" => Some("archipelago"),
|
||||
"lnd" | "lnd-ui" => Some("lnd"),
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => Some("btcpay"),
|
||||
"mempool" | "mempool-web" | "mempool-frontend" => Some("mempool"),
|
||||
"fedimint" => Some("fedimint"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// V3 onion addresses are 56 base32 chars + ".onion". Placeholders like "btcpay.onion" are not real.
|
||||
fn is_real_onion_address(s: &str) -> bool {
|
||||
s.ends_with(".onion") && s.len() >= 60 && s.len() <= 70
|
||||
}
|
||||
|
||||
/// Read real .onion address from Tor hidden service hostname file.
|
||||
/// Service name "archipelago" is for the main web UI (nginx port 80).
|
||||
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
|
||||
pub fn read_tor_address(app_id: &str) -> Option<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> {
|
||||
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"
|
||||
|
||||
@ -22,6 +22,10 @@ pub struct ServerInfo {
|
||||
pub status_info: StatusInfo,
|
||||
#[serde(rename = "lan-address")]
|
||||
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,
|
||||
#[serde(rename = "wifi-ssids")]
|
||||
pub wifi_ssids: Vec<String>,
|
||||
@ -225,6 +229,8 @@ impl DataModel {
|
||||
update_progress: None,
|
||||
},
|
||||
lan_address: Some("http://localhost:8100".to_string()),
|
||||
tor_address: None,
|
||||
node_address: None,
|
||||
unread: 0,
|
||||
wifi_ssids: vec![],
|
||||
zram_enabled: false,
|
||||
|
||||
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 auth;
|
||||
mod config;
|
||||
mod electrs_status;
|
||||
mod container;
|
||||
mod port_allocator;
|
||||
mod data_model;
|
||||
mod identity;
|
||||
mod node_message;
|
||||
mod nostr_discovery;
|
||||
mod peers;
|
||||
mod server;
|
||||
mod state;
|
||||
|
||||
|
||||
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::config::{Config, ContainerRuntime};
|
||||
use crate::container::DockerPackageScanner;
|
||||
use crate::container::{docker_packages, DockerPackageScanner};
|
||||
use crate::identity::{self, NodeIdentity};
|
||||
use crate::nostr_discovery;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use hyper::server::conn::Http;
|
||||
@ -13,6 +15,7 @@ use tracing::{debug, error, info};
|
||||
|
||||
pub struct Server {
|
||||
_config: Config,
|
||||
_identity: Arc<NodeIdentity>,
|
||||
api_handler: Arc<ApiHandler>,
|
||||
_state_manager: Arc<StateManager>,
|
||||
}
|
||||
@ -20,17 +23,83 @@ pub struct Server {
|
||||
impl Server {
|
||||
pub async fn new(config: Config) -> Result<Self> {
|
||||
let state_manager = Arc::new(StateManager::new());
|
||||
|
||||
// Load node identity and set stable server_info
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let identity = NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
data.server_info.id = identity.node_id();
|
||||
data.server_info.pubkey = identity.pubkey_hex();
|
||||
data.server_info.tor_address = docker_packages::read_tor_address("archipelago");
|
||||
if let Some(ref tor) = data.server_info.tor_address {
|
||||
data.server_info.node_address = Some(identity.node_address(tor));
|
||||
}
|
||||
state_manager.update_data(data.clone()).await;
|
||||
|
||||
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
|
||||
if let Err(e) = nostr_discovery::revoke_if_needed(&identity_dir, tor_proxy_revoke.as_deref()).await {
|
||||
tracing::debug!("Nostr revoke (non-fatal): {}", e);
|
||||
}
|
||||
|
||||
// Publish node identity to Nostr only when opt-in (nostr_discovery_enabled + relays)
|
||||
if config.nostr_discovery_enabled
|
||||
&& !config.nostr_relays.is_empty()
|
||||
&& data.server_info.node_address.is_some()
|
||||
{
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let node_addr = data.server_info.node_address.clone().unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = config.nostr_relays.clone();
|
||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_discovery::publish_node_identity(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&node_addr,
|
||||
&version,
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Nostr publish (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
info!("🔑 Node identity: {} (pubkey: {}...)", identity.node_id(), &identity.pubkey_hex()[..16.min(identity.pubkey_hex().len())]);
|
||||
|
||||
let identity = Arc::new(identity);
|
||||
let api_handler = Arc::new(ApiHandler::new(config.clone(), state_manager.clone()).await?);
|
||||
|
||||
// Periodic Tor address refresh (runs regardless of dev_mode)
|
||||
// Picks up hostname when Tor creates it after startup/rotation (30-60s delay)
|
||||
{
|
||||
let state = state_manager.clone();
|
||||
let identity_clone = identity.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = refresh_tor_address(&state, identity_clone.as_ref()).await {
|
||||
debug!("Tor address refresh (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Docker scanner if in dev mode
|
||||
if config.dev_mode {
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
let state = state_manager.clone();
|
||||
let identity_clone = identity.clone();
|
||||
|
||||
// Initial scan
|
||||
tokio::spawn(async move {
|
||||
info!("🐳 Scanning Docker containers...");
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
|
||||
error!("Failed to scan Docker containers: {}", e);
|
||||
}
|
||||
|
||||
@ -38,7 +107,7 @@ impl Server {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(10));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state, identity_clone.as_ref()).await {
|
||||
error!("Failed to update Docker containers: {}", e);
|
||||
}
|
||||
}
|
||||
@ -47,6 +116,7 @@ impl Server {
|
||||
|
||||
Ok(Self {
|
||||
_config: config,
|
||||
_identity: identity,
|
||||
api_handler,
|
||||
_state_manager: state_manager,
|
||||
})
|
||||
@ -108,25 +178,42 @@ async fn create_docker_scanner(config: &Config) -> Result<DockerPackageScanner>
|
||||
Ok(DockerPackageScanner::new(runtime))
|
||||
}
|
||||
|
||||
async fn refresh_tor_address(state: &StateManager, identity: &NodeIdentity) -> Result<()> {
|
||||
let tor_addr = docker_packages::read_tor_address("archipelago");
|
||||
let (current_data, _) = state.get_snapshot().await;
|
||||
if tor_addr != current_data.server_info.tor_address {
|
||||
let mut data = current_data;
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
state.update_data(data).await;
|
||||
if let Some(ref addr) = tor_addr {
|
||||
info!("🔒 Tor address updated: {}", addr);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn scan_and_update_packages(
|
||||
scanner: &DockerPackageScanner,
|
||||
state: &StateManager,
|
||||
identity: &NodeIdentity,
|
||||
) -> Result<()> {
|
||||
let packages = scanner.scan_containers().await?;
|
||||
|
||||
// Only update if we have packages AND they're different from current state
|
||||
if !packages.is_empty() {
|
||||
let (current_data, _) = state.get_snapshot().await;
|
||||
let packages_changed = !packages.is_empty() && current_data.package_data != packages;
|
||||
let tor_addr = docker_packages::read_tor_address("archipelago");
|
||||
let tor_changed = tor_addr != current_data.server_info.tor_address;
|
||||
|
||||
// Check if packages actually changed to avoid unnecessary broadcasts
|
||||
let packages_changed = current_data.package_data != packages;
|
||||
|
||||
if packages_changed {
|
||||
if packages_changed || tor_changed {
|
||||
let mut data = current_data;
|
||||
if !packages.is_empty() {
|
||||
data.package_data = packages;
|
||||
state.update_data(data).await;
|
||||
debug!("📦 Container state changed, broadcasting update");
|
||||
}
|
||||
data.server_info.tor_address = tor_addr.clone();
|
||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||
state.update_data(data).await;
|
||||
debug!("📦 State changed (packages={}, tor={}), broadcasting update", packages_changed, tor_changed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -97,18 +97,27 @@ services:
|
||||
networks:
|
||||
- archy-net
|
||||
|
||||
# Fedimint (using guardians setup)
|
||||
# Fedimint (v0.10+ with built-in Guardian UI)
|
||||
fedimint:
|
||||
image: fedimint/fedimintd:v0.3.0
|
||||
image: fedimint/fedimintd:v0.10.0
|
||||
container_name: archy-fedimint
|
||||
platform: linux/amd64 # Emulate x86 on ARM Macs
|
||||
ports:
|
||||
- "8173:8173"
|
||||
- "8173:8173" # P2P
|
||||
- "8174:8174" # API (JSON-RPC)
|
||||
- "8175:8175" # Built-in Guardian UI
|
||||
volumes:
|
||||
- fedimint-data:/data
|
||||
environment:
|
||||
FM_BITCOIND_URL: http://bitcoin:18443
|
||||
FM_BITCOIND_USERNAME: bitcoin
|
||||
FM_BITCOIND_PASSWORD: bitcoinpass
|
||||
FM_BITCOIN_NETWORK: regtest
|
||||
FM_BIND_P2P: 0.0.0.0:8173
|
||||
FM_BIND_API: 0.0.0.0:8174
|
||||
FM_BIND_UI: 0.0.0.0:8175
|
||||
depends_on:
|
||||
- bitcoin
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- archy-net
|
||||
|
||||
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 {
|
||||
listen 8081;
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
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 " ─────────────────────────────────────────────────────────────"
|
||||
echo ""
|
||||
echo " r) Refresh - Update IP/status (no restart needed)"
|
||||
echo " w) Open Web UI - Launch graphical interface"
|
||||
echo ""
|
||||
echo " 1) Install to Disk - Permanently install Archipelago"
|
||||
@ -133,6 +134,9 @@ main_menu() {
|
||||
read -p " Select option: " choice
|
||||
|
||||
case $choice in
|
||||
r|R)
|
||||
# Refresh - just loop again to show updated IP/status
|
||||
;;
|
||||
w|W)
|
||||
echo ""
|
||||
# Start the real backend on port 5678
|
||||
|
||||
@ -68,6 +68,9 @@ check_tools() {
|
||||
if ! command -v xorriso >/dev/null 2>&1; then
|
||||
missing="$missing xorriso"
|
||||
fi
|
||||
if ! command -v 7z >/dev/null 2>&1 && ! command -v 7za >/dev/null 2>&1; then
|
||||
missing="$missing p7zip-full"
|
||||
fi
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "❌ Missing required tools:$missing"
|
||||
@ -79,6 +82,9 @@ check_tools() {
|
||||
if [[ "$missing" == *"xorriso"* ]]; then
|
||||
apt-get install -y xorriso
|
||||
fi
|
||||
if [[ "$missing" == *"p7zip-full"* ]]; then
|
||||
apt-get install -y p7zip-full
|
||||
fi
|
||||
|
||||
if [[ "$missing" == *"docker-or-podman"* ]]; then
|
||||
echo " Installing podman..."
|
||||
@ -208,12 +214,24 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
location /archipelago/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /rpc/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Increase timeout for long-running operations (e.g., Docker image pulls)
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Proxy WebSocket
|
||||
@ -223,6 +241,7 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
NGINXCONF
|
||||
@ -268,7 +287,7 @@ echo "📦 Step 2: Creating installer environment..."
|
||||
|
||||
# Download Debian Live as our installer base
|
||||
BASE_ISO="$WORK_DIR/debian-live-installer.iso"
|
||||
EXPECTED_SIZE=369000000 # ~352MB
|
||||
EXPECTED_SIZE=1500000000 # ~1.5GB min (Debian 13 Live standard ~1.9GB)
|
||||
|
||||
# Check if file exists and is complete
|
||||
if [ -f "$BASE_ISO" ]; then
|
||||
@ -287,7 +306,7 @@ if [ ! -f "$BASE_ISO" ]; then
|
||||
|
||||
# Use wget without -O so --continue actually works
|
||||
# Download with the ugly SourceForge filename, then rename
|
||||
ISO_URL="https://sourceforge.net/projects/debian-live-respin-iso/files/standard/live-image-debian12.11-standard-20250522-amd64.hybrid.iso/download"
|
||||
ISO_URL="https://cdimage.debian.org/debian-cd/current-live/amd64/iso-hybrid/debian-live-13.3.0-amd64-standard.iso"
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
cd "$WORK_DIR"
|
||||
@ -302,8 +321,8 @@ if [ ! -f "$BASE_ISO" ]; then
|
||||
# Find the downloaded file (wget creates it with a name like "download" or the actual filename)
|
||||
if [ -f "download" ]; then
|
||||
mv "download" "$BASE_ISO"
|
||||
elif [ -f "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" ]; then
|
||||
mv "live-image-debian12.11-standard-20250522-amd64.hybrid.iso" "$BASE_ISO"
|
||||
elif [ -f "debian-live-13.3.0-amd64-standard.iso" ]; then
|
||||
mv "debian-live-13.3.0-amd64-standard.iso" "$BASE_ISO"
|
||||
else
|
||||
echo " ❌ Downloaded file not found"
|
||||
exit 1
|
||||
@ -335,7 +354,10 @@ INSTALLER_ISO="$WORK_DIR/installer-iso"
|
||||
rm -rf "$INSTALLER_ISO"
|
||||
mkdir -p "$INSTALLER_ISO"
|
||||
cd "$INSTALLER_ISO"
|
||||
7z x -y "$BASE_ISO" >/dev/null 2>&1 || 7z x -y "$BASE_ISO"
|
||||
(7z x -y "$BASE_ISO" 2>/dev/null || 7za x -y "$BASE_ISO" 2>/dev/null || bsdtar -xf "$BASE_ISO" 2>/dev/null) || {
|
||||
echo " ❌ Failed to extract ISO. Install p7zip-full: sudo apt install p7zip-full"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: Add Archipelago components
|
||||
@ -362,17 +384,15 @@ fi
|
||||
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
||||
BACKEND_CAPTURED=0
|
||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
# Check if we're running on the server itself (localhost or same machine)
|
||||
if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then
|
||||
# Direct copy from local filesystem
|
||||
# Direct copy from local filesystem (when running on target with sudo)
|
||||
if [ -f "/usr/local/bin/archipelago" ]; then
|
||||
cp "/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago"
|
||||
chmod +x "$ARCH_DIR/bin/archipelago"
|
||||
echo " ✅ Backend captured from local system ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
||||
BACKEND_CAPTURED=1
|
||||
fi
|
||||
else
|
||||
# Remote copy via SCP
|
||||
# Remote copy via SCP if local failed
|
||||
if [ "$BACKEND_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||
if scp "$DEV_SERVER:/usr/local/bin/archipelago" "$ARCH_DIR/bin/archipelago" 2>/dev/null; then
|
||||
chmod +x "$ARCH_DIR/bin/archipelago"
|
||||
echo " ✅ Backend captured from remote server ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
||||
@ -416,16 +436,14 @@ mkdir -p "$ARCH_DIR/web-ui"
|
||||
# Try to get from live server first (unless BUILD_FROM_SOURCE=1)
|
||||
WEBUI_CAPTURED=0
|
||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
# Check if we're running on the server itself
|
||||
if [ "$DEV_SERVER" = "localhost" ] || [ "$DEV_SERVER" = "127.0.0.1" ]; then
|
||||
# Direct copy from local filesystem
|
||||
# Direct copy from local filesystem (when running on target with sudo)
|
||||
if [ -d "/opt/archipelago/web-ui" ] && [ "$(ls -A /opt/archipelago/web-ui 2>/dev/null)" ]; then
|
||||
cp -r /opt/archipelago/web-ui/* "$ARCH_DIR/web-ui/"
|
||||
echo " ✅ Web UI captured from local system ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
||||
WEBUI_CAPTURED=1
|
||||
fi
|
||||
else
|
||||
# Remote copy via rsync
|
||||
# Remote copy via rsync if local failed
|
||||
if [ "$WEBUI_CAPTURED" = "0" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||
if rsync -az "$DEV_SERVER:/opt/archipelago/web-ui/" "$ARCH_DIR/web-ui/" 2>/dev/null && [ "$(ls -A "$ARCH_DIR/web-ui")" ]; then
|
||||
echo " ✅ Web UI captured from remote server ($(du -sh "$ARCH_DIR/web-ui" | cut -f1))"
|
||||
WEBUI_CAPTURED=1
|
||||
@ -481,7 +499,7 @@ mkdir -p "$IMAGES_DIR"
|
||||
IMAGES_CAPTURED_FROM_SERVER=0
|
||||
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
|
||||
echo " Capturing container images from live server ($DEV_SERVER)..."
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoin-knots lnd lnd-ui filebrowser mempool tailscale homeassistant btcpayserver nostr-rs-relay strfry"
|
||||
CAPTURE_PATTERNS="bitcoin-ui bitcoin-knots lnd lnd-ui filebrowser mempool mempool-electrs tailscale homeassistant btcpayserver nostr-rs-relay strfry alpine-tor"
|
||||
REMOTE_TMP="/tmp/archipelago-image-capture-$$"
|
||||
SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(sudo podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && sudo podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true
|
||||
for p in $SAVED_LIST; do
|
||||
@ -503,11 +521,16 @@ bitcoinknots/bitcoin:29 bitcoin-knots.tar
|
||||
lightninglabs/lnd:v0.18.4-beta lnd.tar
|
||||
ghcr.io/home-assistant/home-assistant:stable homeassistant.tar
|
||||
btcpayserver/btcpayserver:latest btcpayserver.tar
|
||||
mempool/frontend:latest mempool.tar
|
||||
mempool/frontend:latest mempool-frontend.tar
|
||||
mempool/backend:v2.5.0 mempool-backend.tar
|
||||
mempool/electrs:latest mempool-electrs.tar
|
||||
docker.io/mariadb:10.11 mariadb-mempool.tar
|
||||
docker.io/fedimint/fedimintd:v0.10.0 fedimint.tar
|
||||
docker.io/filebrowser/filebrowser:latest filebrowser.tar
|
||||
scsibug/nostr-rs-relay:latest nostr-rs-relay.tar
|
||||
hoytech/strfry:latest strfry.tar
|
||||
tailscale/tailscale:latest tailscale.tar
|
||||
docker.io/andrius/alpine-tor:latest alpine-tor.tar
|
||||
"
|
||||
|
||||
# Pull and save each image (force AMD64 for x86_64 target) only if not already present
|
||||
@ -571,6 +594,9 @@ for tarfile in "$IMAGES_DIR"/*.tar; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure archy-net exists for mempool stack (db, api, frontend)
|
||||
podman network create archy-net 2>/dev/null || true
|
||||
|
||||
echo "$(date): Container image load complete" >> "$LOG_FILE"
|
||||
echo "$(date): Available images:" >> "$LOG_FILE"
|
||||
podman images >> "$LOG_FILE" 2>&1
|
||||
@ -583,7 +609,85 @@ mkdir -p "$ARCH_DIR/scripts"
|
||||
cp "$WORK_DIR/load-container-images.sh" "$ARCH_DIR/scripts/"
|
||||
cp "$WORK_DIR/archipelago-load-images.service" "$ARCH_DIR/scripts/"
|
||||
|
||||
echo " ✅ Container images bundled"
|
||||
# Tor setup: copy torrc and create first-boot setup script
|
||||
mkdir -p "$ARCH_DIR/scripts/tor"
|
||||
if [ -f "$SCRIPT_DIR/../scripts/tor/torrc.template" ]; then
|
||||
cp "$SCRIPT_DIR/../scripts/tor/torrc.template" "$ARCH_DIR/scripts/tor/torrc"
|
||||
fi
|
||||
|
||||
echo " Creating first-boot Tor setup service..."
|
||||
cat > "$WORK_DIR/archipelago-setup-tor.service" <<'TORSERVICE'
|
||||
[Unit]
|
||||
Description=Setup and start Archipelago Tor hidden services
|
||||
After=archipelago-load-images.service network.target podman.service
|
||||
ConditionPathExists=/opt/archipelago/scripts/setup-tor.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/archipelago/scripts/setup-tor.sh
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
TORSERVICE
|
||||
|
||||
cat > "$WORK_DIR/setup-tor.sh" <<'TORSCRIPT'
|
||||
#!/bin/bash
|
||||
# Setup and start Tor container for unique .onion addresses (autoinstaller first-boot)
|
||||
|
||||
TOR_DIR="/var/lib/archipelago/tor"
|
||||
TORRC_SRC="/opt/archipelago/scripts/tor/torrc"
|
||||
LOG="/var/log/archipelago-tor.log"
|
||||
|
||||
mkdir -p "$TOR_DIR"
|
||||
if [ -f "$TORRC_SRC" ]; then
|
||||
cp "$TORRC_SRC" "$TOR_DIR/torrc"
|
||||
fi
|
||||
if [ ! -f "$TOR_DIR/torrc" ]; then
|
||||
echo "SocksPort 9050" > "$TOR_DIR/torrc"
|
||||
echo "ControlPort 0" >> "$TOR_DIR/torrc"
|
||||
echo "DataDirectory $TOR_DIR" >> "$TOR_DIR/torrc"
|
||||
echo "HiddenServiceDir $TOR_DIR/hidden_service_archipelago/" >> "$TOR_DIR/torrc"
|
||||
echo "HiddenServicePort 80 127.0.0.1:80" >> "$TOR_DIR/torrc"
|
||||
fi
|
||||
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
|
||||
for c in $(sudo $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor$'); do
|
||||
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||
done
|
||||
|
||||
if ! sudo $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-tor; then
|
||||
if sudo $DOCKER run -d --name archy-tor --restart unless-stopped --network host \
|
||||
-v "$TOR_DIR:$TOR_DIR" \
|
||||
--entrypoint tor \
|
||||
docker.io/andrius/alpine-tor:latest \
|
||||
-f "$TOR_DIR/torrc" >> "$LOG" 2>&1; then
|
||||
echo "$(date): Tor container started" >> "$LOG"
|
||||
fi
|
||||
fi
|
||||
# Wait for Tor to create hostname files (~30-60s), then chmod so archipelago user can read
|
||||
# (Backend runs as archipelago and needs node_address for Nostr peer discovery)
|
||||
# Must chmod parent dirs (711=traverse) and hostname files (644) - Tor creates 700 dirs
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
sleep 6
|
||||
if [ -f "$TOR_DIR/hidden_service_archipelago/hostname" ]; then
|
||||
chmod 711 "$TOR_DIR" "$TOR_DIR"/hidden_service_*/
|
||||
for f in "$TOR_DIR"/hidden_service_*/hostname; do
|
||||
[ -f "$f" ] && chmod 644 "$f" && echo "$(date): chmod hostname $f" >> "$LOG"
|
||||
done
|
||||
echo "$(date): Tor hostname files readable by archipelago" >> "$LOG"
|
||||
break
|
||||
fi
|
||||
done
|
||||
TORSCRIPT
|
||||
|
||||
chmod +x "$WORK_DIR/setup-tor.sh"
|
||||
cp "$WORK_DIR/setup-tor.sh" "$ARCH_DIR/scripts/"
|
||||
cp "$WORK_DIR/archipelago-setup-tor.service" "$ARCH_DIR/scripts/"
|
||||
|
||||
echo " ✅ Container images bundled (including Tor)"
|
||||
|
||||
# =============================================================================
|
||||
# STEP 4: Create auto-installer script
|
||||
@ -813,6 +917,17 @@ if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
|
||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/archipelago-load-images.service" ]; then
|
||||
cp "$BOOT_MEDIA/archipelago/scripts/archipelago-load-images.service" /mnt/target/etc/systemd/system/
|
||||
fi
|
||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/setup-tor.sh" ]; then
|
||||
cp "$BOOT_MEDIA/archipelago/scripts/setup-tor.sh" /mnt/target/opt/archipelago/scripts/
|
||||
chmod +x /mnt/target/opt/archipelago/scripts/setup-tor.sh
|
||||
fi
|
||||
if [ -d "$BOOT_MEDIA/archipelago/scripts/tor" ]; then
|
||||
mkdir -p /mnt/target/opt/archipelago/scripts/tor
|
||||
cp -r "$BOOT_MEDIA/archipelago/scripts/tor/"* /mnt/target/opt/archipelago/scripts/tor/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/archipelago-setup-tor.service" ]; then
|
||||
cp "$BOOT_MEDIA/archipelago/scripts/archipelago-setup-tor.service" /mnt/target/etc/systemd/system/
|
||||
fi
|
||||
|
||||
echo " ✅ Container images staged for first-boot loading"
|
||||
fi
|
||||
@ -873,7 +988,7 @@ chmod +x /mnt/target/etc/profile.d/archipelago.sh
|
||||
cat > /mnt/target/etc/systemd/system/archipelago.service <<'SERVICE'
|
||||
[Unit]
|
||||
Description=Archipelago Backend
|
||||
After=network-online.target
|
||||
After=network-online.target archipelago-setup-tor.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
@ -907,6 +1022,7 @@ chroot /mnt/target update-grub
|
||||
chroot /mnt/target systemctl enable archipelago.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable nginx.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-load-images.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
||||
|
||||
# Cleanup
|
||||
sync
|
||||
|
||||
@ -8,6 +8,9 @@ Type=simple
|
||||
User=root
|
||||
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
|
||||
Environment="ARCHIPELAGO_DEV_MODE=true"
|
||||
# Host IP for container env vars (FM_P2P_URL, etc.) - detected at startup if unset
|
||||
EnvironmentFile=-/etc/archipelago/host-ip.env
|
||||
ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk \"{print \\$1}\")" > /etc/archipelago/host-ip.env'
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@ -11,6 +11,14 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
location /archipelago/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /rpc/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
|
||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.su3n1rkrf7k"
|
||||
"revision": "0.8432ene9gn8"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@ -140,6 +140,7 @@ async function getDockerContainers() {
|
||||
'archy-morphos': 'morphos-server',
|
||||
'archy-lnd': 'lightning-stack',
|
||||
'archy-mempool-web': 'mempool',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
'archy-ollama': 'ollama',
|
||||
'archy-searxng': 'searxng',
|
||||
'archy-onlyoffice': 'onlyoffice',
|
||||
@ -187,7 +188,7 @@ async function getDockerContainers() {
|
||||
},
|
||||
'fedimint': {
|
||||
title: 'Fedimint',
|
||||
icon: '/assets/img/icon-fedimint.jpeg',
|
||||
icon: '/assets/img/app-icons/fedimint.png',
|
||||
description: 'Federated Bitcoin mint'
|
||||
},
|
||||
'morphos-server': {
|
||||
@ -205,6 +206,11 @@ async function getDockerContainers() {
|
||||
icon: '/assets/img/app-icons/mempool.png',
|
||||
description: 'Bitcoin blockchain explorer'
|
||||
},
|
||||
'mempool-electrs': {
|
||||
title: 'Electrs',
|
||||
icon: '/assets/img/app-icons/electrs.svg',
|
||||
description: 'Electrum protocol indexer for Bitcoin'
|
||||
},
|
||||
'ollama': {
|
||||
title: 'Ollama',
|
||||
icon: '/assets/img/app-icons/ollama.png',
|
||||
@ -623,13 +629,25 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'auth.onboardingComplete': {
|
||||
userState.onboardingComplete = true
|
||||
console.log(`[Auth] Onboarding completed`)
|
||||
return res.json({ result: { success: true } })
|
||||
return res.json({ result: true })
|
||||
}
|
||||
|
||||
case 'auth.isOnboardingComplete': {
|
||||
return res.json({ result: userState.onboardingComplete })
|
||||
}
|
||||
|
||||
case 'node.did': {
|
||||
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
|
||||
const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
|
||||
return res.json({ result: { did: mockDid, pubkey: mockPubkey } })
|
||||
}
|
||||
case 'node.nostr-publish': {
|
||||
return res.json({ result: { event_id: 'mock-event-id', success: 2, failed: 0 } })
|
||||
}
|
||||
case 'node.nostr-pubkey': {
|
||||
return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } })
|
||||
}
|
||||
|
||||
case 'auth.login': {
|
||||
const { password } = params
|
||||
|
||||
|
||||
10
neode-ui/package-lock.json
generated
10
neode-ui/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.9",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3"
|
||||
@ -4878,6 +4879,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.9",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3"
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
// Custom icon URLs for apps without Start9 repos
|
||||
const customIconUrls = {
|
||||
'fedimint': [
|
||||
'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg',
|
||||
],
|
||||
}
|
||||
|
||||
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
|
||||
|
||||
// Ensure directory exists
|
||||
@ -82,32 +89,48 @@ function downloadFile(url, filepath) {
|
||||
}
|
||||
|
||||
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']
|
||||
const filepath = path.join(iconDir, `${appId}.webp`)
|
||||
|
||||
// Try multiple icon paths
|
||||
const iconPaths = [
|
||||
`icon.png`,
|
||||
`icon.svg`,
|
||||
`assets/icon.png`,
|
||||
`assets/icon.svg`,
|
||||
]
|
||||
// Skip if file already exists
|
||||
if (appId === 'fedimint' && fs.existsSync(path.join(iconDir, 'fedimint.png'))) {
|
||||
console.log(`⏭️ Skipping ${appId} (fedimint.png exists)`)
|
||||
return true
|
||||
}
|
||||
for (const ext of fallbackExts) {
|
||||
const fp = path.join(iconDir, `${appId}.${ext}`)
|
||||
if (fs.existsSync(fp)) {
|
||||
console.log(`⏭️ Skipping ${appId} (already exists)`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Try custom URLs first (e.g. fedimint from fedimint-ui)
|
||||
if (customIconUrls[appId]) {
|
||||
for (const url of customIconUrls[appId]) {
|
||||
try {
|
||||
const ext = url.endsWith('.svg') ? 'svg' : (url.endsWith('.png') ? 'png' : 'webp')
|
||||
const fp = path.join(iconDir, `${appId}.${ext}`)
|
||||
await downloadFile(url, fp)
|
||||
return true
|
||||
} catch (err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repoName = repoMap[appId] || `${appId}-startos`
|
||||
const iconPaths = ['icon.png', 'icon.svg', 'assets/icon.png', 'assets/icon.svg']
|
||||
|
||||
for (const iconPath of iconPaths) {
|
||||
const url = `https://raw.githubusercontent.com/Start9Labs/${repoName}/main/${iconPath}`
|
||||
const extension = iconPath.endsWith('.svg') ? 'svg' : 'png'
|
||||
const filepath = path.join(iconDir, `${appId}.${extension}`)
|
||||
|
||||
// Skip if file already exists
|
||||
if (fs.existsSync(filepath)) {
|
||||
console.log(`⏭️ Skipping ${appId} (already exists)`)
|
||||
return true
|
||||
}
|
||||
|
||||
const fp = path.join(iconDir, `${appId}.${extension}`)
|
||||
try {
|
||||
await downloadFile(url, filepath)
|
||||
await downloadFile(url, fp)
|
||||
return true
|
||||
} catch (err) {
|
||||
// Try next path
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,18 +6,46 @@
|
||||
<!-- Main App Content - only show after splash and routing is complete -->
|
||||
<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 -->
|
||||
<PWAUpdatePrompt />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.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 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 showSplash = ref(true)
|
||||
const isReady = ref(false)
|
||||
@ -29,6 +57,7 @@ const isReady = ref(false)
|
||||
* - User is on a direct route (refresh/bookmark)
|
||||
*/
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
|
||||
@ -43,48 +72,33 @@ onMounted(() => {
|
||||
// SplashScreen will emit 'complete' which calls handleSplashComplete
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
document.body.classList.add('splash-complete')
|
||||
|
||||
// Set isReady first so RouterView can render
|
||||
isReady.value = true
|
||||
|
||||
// Determine destination based on onboarding status and dev mode
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
// const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
||||
|
||||
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') {
|
||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
}
|
||||
// Existing user mode: go to login
|
||||
else if (devMode === 'existing') {
|
||||
destination = '/login'
|
||||
}
|
||||
// Default: check onboarding status
|
||||
else {
|
||||
destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
try {
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
</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> {
|
||||
return this.call({
|
||||
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> {
|
||||
return this.call({
|
||||
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: [
|
||||
{
|
||||
path: '',
|
||||
redirect: (_to) => {
|
||||
// Initial routing logic - determines first screen after splash
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
// const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
||||
|
||||
// Setup mode: go directly to login (original StartOS setup)
|
||||
if (devMode === 'setup') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// Onboarding mode: go to experimental onboarding flow
|
||||
if (devMode === 'onboarding') {
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
}
|
||||
|
||||
// Existing user mode: go to login
|
||||
if (devMode === 'existing') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// Default: check if user has completed onboarding
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
},
|
||||
component: () => import('../views/RootRedirect.vue'),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
|
||||
@ -164,6 +164,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
|
||||
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 utilities;
|
||||
|
||||
/* Controller / keyboard navigation - game-like focus ring */
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Global glassmorphism utilities */
|
||||
@layer components {
|
||||
.glass {
|
||||
@ -32,6 +38,13 @@
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
padding-block: 0 !important;
|
||||
line-height: 48px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
@ -39,6 +52,34 @@
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.glass-button-sm {
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit;
|
||||
padding-block: 0.375rem !important;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
/* Toast - glassmorphic, top-right */
|
||||
.toast-glass {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Toast transition */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
/* Gradient containers - transparent to black */
|
||||
.gradient-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
@ -91,7 +132,8 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logo-gradient-border img {
|
||||
.logo-gradient-border img,
|
||||
.logo-gradient-border svg {
|
||||
border-radius: 9999px;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
@ -13,6 +13,8 @@ export interface ServerInfo {
|
||||
pubkey: string
|
||||
'status-info': StatusInfo
|
||||
'lan-address': string | null
|
||||
'tor-address': string | null
|
||||
'node-address'?: string
|
||||
unread: number
|
||||
'wifi-ssids': string[]
|
||||
'zram-enabled': boolean
|
||||
@ -48,6 +50,7 @@ export const PackageState = {
|
||||
Installed: 'installed',
|
||||
Stopping: 'stopping',
|
||||
Stopped: 'stopped',
|
||||
Exited: 'exited',
|
||||
Starting: 'starting',
|
||||
Running: 'running',
|
||||
Restarting: 'restarting',
|
||||
|
||||
@ -191,7 +191,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Federated Bitcoin mint',
|
||||
icon: '/assets/img/icon-fedimint.jpeg'
|
||||
icon: '/assets/img/app-icons/fedimint.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'fedimint',
|
||||
@ -216,7 +216,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'fedimint.onion',
|
||||
'lan-address': 'http://localhost:8173'
|
||||
'lan-address': 'http://localhost:8175'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
|
||||
@ -268,6 +268,39 @@
|
||||
</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 -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
@ -407,17 +440,69 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
// Check both store.packages and dummyApps
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
const ROUTE_TO_PACKAGE_KEY: Record<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(() => {
|
||||
// First check real packages
|
||||
if (store.packages[appId.value]) {
|
||||
return store.packages[appId.value]
|
||||
const routeId = appId.value
|
||||
const packageKey = resolvePackageKey(routeId)
|
||||
// First check real packages (try both route id and resolved key)
|
||||
if (store.packages[packageKey]) {
|
||||
return store.packages[packageKey]
|
||||
}
|
||||
if (store.packages[routeId]) {
|
||||
return store.packages[routeId]
|
||||
}
|
||||
// Fall back to dummy apps
|
||||
if (dummyApps[appId.value]) {
|
||||
return dummyApps[appId.value]
|
||||
if (dummyApps[routeId]) {
|
||||
return dummyApps[routeId]
|
||||
}
|
||||
return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
const interfaceAddresses = computed(() => {
|
||||
const main = pkg.value?.installed?.['interface-addresses']?.main
|
||||
if (!main) return null
|
||||
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
||||
return main
|
||||
})
|
||||
|
||||
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
|
||||
function isRealOnionAddress(addr: string | undefined): boolean {
|
||||
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
|
||||
}
|
||||
|
||||
const lanUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['lan-address']
|
||||
if (!addr) return '#'
|
||||
if (addr.includes('localhost')) {
|
||||
return addr.replace('localhost', window.location.hostname)
|
||||
}
|
||||
return addr
|
||||
})
|
||||
|
||||
/** Tor URL with http:// prefix for copy-paste into Tor Browser */
|
||||
const torUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['tor-address']
|
||||
if (!addr || !isRealOnionAddress(addr)) return ''
|
||||
return addr.startsWith('http') ? addr : `http://${addr}`
|
||||
})
|
||||
|
||||
const uninstallModal = ref({
|
||||
@ -543,8 +628,8 @@ function launchApp() {
|
||||
prod: 'http://localhost:8080'
|
||||
},
|
||||
'fedimint': {
|
||||
dev: 'http://localhost:8173',
|
||||
prod: 'http://localhost:8173'
|
||||
dev: 'http://localhost:8175',
|
||||
prod: 'http://192.168.1.228:8175'
|
||||
},
|
||||
'morphos-server': {
|
||||
dev: 'http://localhost:8081',
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<div
|
||||
v-for="[id, pkg] in sortedPackageEntries"
|
||||
: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)"
|
||||
>
|
||||
<!-- Uninstall Icon -->
|
||||
@ -48,8 +48,8 @@
|
||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
||||
{{ pkg.manifest.title }}
|
||||
</h3>
|
||||
<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);">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="logo-gradient-border flex-shrink-0">
|
||||
<img src="/assets/icon/favico-black.svg" alt="Neode" class="w-14 h-14" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ serverName }}</h2>
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">v{{ version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -76,6 +74,11 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Controller indicator - Desktop sidebar -->
|
||||
<div class="px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
|
||||
<!-- User Section - Desktop Only -->
|
||||
<div class="p-6">
|
||||
<button
|
||||
@ -114,6 +117,30 @@
|
||||
</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 -->
|
||||
<div
|
||||
v-if="showAppsTabs"
|
||||
@ -204,12 +231,11 @@
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
: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
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)]'
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
|
||||
: undefined
|
||||
]"
|
||||
:style="contentPaddingBottomStyle"
|
||||
>
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
@ -262,8 +288,13 @@
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||
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 messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@ -437,9 +468,6 @@ watch(() => route.path, (newPath) => {
|
||||
|
||||
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
|
||||
const showAppsTabs = computed(() => {
|
||||
@ -501,6 +529,7 @@ function updateNetworkTabIndicator() {
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
messageToast.startPolling()
|
||||
updateAppsTabIndicator()
|
||||
updateNetworkTabIndicator()
|
||||
|
||||
@ -513,6 +542,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
messageToast.stopPolling()
|
||||
})
|
||||
|
||||
// Watch route changes to update indicator position
|
||||
|
||||
@ -18,8 +18,8 @@
|
||||
<!-- Section Overviews -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- My Apps Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<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>
|
||||
@ -34,7 +34,7 @@
|
||||
</RouterLink>
|
||||
</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">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@ -62,8 +62,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
@ -78,7 +78,7 @@
|
||||
</RouterLink>
|
||||
</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">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||
@ -89,7 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@ -106,8 +106,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<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>
|
||||
@ -122,7 +122,7 @@
|
||||
</RouterLink>
|
||||
</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 gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@ -146,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@ -165,8 +165,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<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>
|
||||
@ -181,7 +181,7 @@
|
||||
</RouterLink>
|
||||
</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 gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@ -205,7 +205,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
|
||||
@ -91,10 +91,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Tabs (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg flex-wrap gap-2">
|
||||
<!-- Category Tabs + Search (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
:class="[
|
||||
@ -107,10 +108,16 @@
|
||||
{{ 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>
|
||||
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6 pt-0 md:pt-0">
|
||||
<!-- Search Bar (Mobile - placeholder for later) -->
|
||||
<div class="md:hidden mb-6">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@ -297,7 +304,7 @@
|
||||
<!-- Category Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id; showFilterModal = false"
|
||||
:class="[
|
||||
@ -521,6 +528,15 @@ const allApps = computed(() => {
|
||||
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
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
@ -801,11 +817,11 @@ function getCuratedAppList() {
|
||||
{
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
version: '0.3.0',
|
||||
description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/icon-fedimint.jpeg',
|
||||
version: '0.10.0',
|
||||
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/app-icons/fedimint.png',
|
||||
author: 'Fedimint',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.3.0',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
|
||||
manifestUrl: null,
|
||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
|
||||
<!-- Content Area -->
|
||||
<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) -->
|
||||
<button
|
||||
v-if="!generatedDid"
|
||||
@ -75,7 +77,8 @@
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
@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
|
||||
</button>
|
||||
@ -87,49 +90,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const generatedDid = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const errorMessage = ref<string>('')
|
||||
|
||||
async function generateDid() {
|
||||
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)
|
||||
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
generatedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
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
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Store DID state and continue to backup
|
||||
if (generatedDid.value) {
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({
|
||||
did: generatedDid.value,
|
||||
kid: 'kid:mock'
|
||||
}))
|
||||
if (generatedDid.value && !generatedDid.value.includes('...')) {
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to backup screen
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
@ -96,13 +97,9 @@ function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
async function proceed() {
|
||||
if (selected.value) {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
// For now, just go to login
|
||||
// In a real app, you'd have different flows for each option
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,6 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const verified = ref(false)
|
||||
@ -95,7 +96,6 @@ async function signChallenge() {
|
||||
// Simulate signing challenge
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock signature generation
|
||||
const mockSignature = generateMockSignature()
|
||||
signature.value = mockSignature
|
||||
verified.value = true
|
||||
@ -112,16 +112,14 @@ function generateMockSignature(): string {
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function proceed() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function skipForNow() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
</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 -->
|
||||
<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 -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Services</p>
|
||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
@ -31,20 +31,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Connectivity Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Connectivity</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||
@ -52,19 +52,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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'"
|
||||
>
|
||||
<span
|
||||
@ -75,19 +75,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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
|
||||
</button>
|
||||
@ -98,8 +98,8 @@
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<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">
|
||||
<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" />
|
||||
@ -111,7 +111,7 @@
|
||||
</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 gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -153,14 +153,14 @@
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<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">
|
||||
<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" />
|
||||
@ -172,7 +172,7 @@
|
||||
</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 gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -214,7 +214,7 @@
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="path-option-card cursor-default px-6 py-6 mb-6">
|
||||
@ -43,7 +49,130 @@
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
</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>
|
||||
|
||||
<!-- 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 -->
|
||||
<button
|
||||
@ -72,15 +201,122 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
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() {
|
||||
await store.logout()
|
||||
|
||||
@ -8,14 +8,14 @@
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<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 -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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">
|
||||
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Networking Profits</p>
|
||||
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
||||
</div>
|
||||
@ -23,40 +23,41 @@
|
||||
</div>
|
||||
|
||||
<!-- DID Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
<div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
<button
|
||||
@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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Connection -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Wallet</p>
|
||||
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
||||
@ -64,32 +65,102 @@
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relay Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<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="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>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
||||
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@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
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Bitcoin Domain Name Portfolio -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<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">
|
||||
<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" />
|
||||
@ -101,7 +172,7 @@
|
||||
</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 gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -133,14 +204,14 @@
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Wallet -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<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">
|
||||
<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" />
|
||||
@ -152,7 +223,7 @@
|
||||
</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 gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
@ -182,14 +253,14 @@
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relays -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<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">
|
||||
<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" />
|
||||
@ -201,7 +272,7 @@
|
||||
</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 gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -233,10 +304,116 @@
|
||||
</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
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Protocol Overview Cards -->
|
||||
@ -449,10 +626,26 @@
|
||||
</template>
|
||||
|
||||
<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 didStatus = ref<'active' | 'inactive' | 'pending'>('active')
|
||||
const route = useRoute()
|
||||
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'
|
||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
||||
@ -465,6 +658,130 @@ const connectingWallet = ref(false)
|
||||
// Nostr Relays
|
||||
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() {
|
||||
// TODO: Navigate to DID management or open modal
|
||||
console.log('Managing DIDs...')
|
||||
@ -506,3 +823,4 @@ function manageRelays() {
|
||||
}
|
||||
</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:
|
||||
# ./scripts/deploy-to-target.sh # Sync and 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
|
||||
@ -13,12 +14,16 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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
|
||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||
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}"
|
||||
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 "║ Deploying to Archipelago Target ║"
|
||||
@ -30,13 +35,40 @@ echo ""
|
||||
# Parse arguments
|
||||
QUICK=false
|
||||
LIVE=false
|
||||
BOTH=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--quick) QUICK=true ;;
|
||||
--live) LIVE=true ;;
|
||||
--both) BOTH=true ;;
|
||||
esac
|
||||
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
|
||||
echo "📦 Syncing code..."
|
||||
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 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
|
||||
echo " Restarting services..."
|
||||
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..."
|
||||
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
|
||||
echo " Restarting LND UI container..."
|
||||
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
|
||||
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 " Recreating LND UI container (port 8081)..."
|
||||
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
|
||||
|
||||
# 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 "✅ Deployed to live system!"
|
||||
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 " • BTCPay Server: http://localhost:14142"
|
||||
echo " • Mempool Explorer: http://localhost:4080"
|
||||
echo " • Fedimint: http://localhost:8173"
|
||||
echo " • Fedimint (Guardian UI): http://localhost:8175"
|
||||
echo ""
|
||||
echo " 🏠 Self-Hosted Services:"
|
||||
echo " • Home Assistant: http://localhost:8123"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user