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:
Dorian 2026-02-17 15:03:34 +00:00
parent 6035c93289
commit 1073d9fd2c
73 changed files with 5870 additions and 478 deletions

View File

@ -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
View File

@ -30,6 +30,7 @@ build/
.env
.env.local
.env.*.local
scripts/deploy-config.sh
# Logs
logs/

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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);

View File

@ -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
}
}

View File

@ -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(())
}

View File

@ -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()),
}
}
}

View File

@ -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"

View File

@ -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,

View 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,
}
}

View 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&lt;base58btc(multicodec_ed25519_pub + 32-byte pubkey)&gt;
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()))
}

View File

@ -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;

View 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),
}
}

View 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)
}

View 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)
}

View 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(())
}
}

View File

@ -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(())

View File

@ -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

View 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;"]

View 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>

View 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;
}
}

View File

@ -1,5 +1,5 @@
server {
listen 8081;
listen 80;
server_name _;
root /usr/share/nginx/html;

View 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, heres what they could have and how to respond.
### What Could Have Been Seen
1. **Relay operators** (relay.damus.io, relay.nostr.info):
- Your servers **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 wont 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.

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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"), {

View File

@ -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

View File

@ -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",

View File

@ -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"

View 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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -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
}
}

View File

@ -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>

View File

@ -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',

View 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));
}
/* 045%: squares load in. 45100%: 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>

View 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>

View 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>

View 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>

View 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,
}
}

View 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,
}
}

View 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')
}
}

View 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
}

View File

@ -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',

View File

@ -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,

View 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,
}
})

View 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,
}
})

View File

@ -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;

View File

@ -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',

View File

@ -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

View File

@ -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',

View File

@ -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">

View File

@ -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

View File

@ -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"

View File

@ -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'
}

View File

@ -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)
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))
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
}
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>

View File

@ -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')
}
}

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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()

View File

@ -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>

View 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'

View File

@ -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')"

View 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
View 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.

View 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

View File

@ -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"