feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements

Bitcoin UI:
- Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts)
- Make all asset paths relative for nginx proxy compatibility
- Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely)
- Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332)

HTTPS mixed content fix:
- Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS,
  iframe loads through nginx proxy instead of direct HTTP port
- Prevents browser blocking HTTP iframes inside HTTPS pages
- All Tailscale servers use HTTPS, this was breaking all app iframes

Deploy & first-boot improvements:
- first-boot-containers.sh auto-detects disk size for pruning vs txindex
- first-boot-containers.sh checks fallback source path for UI containers
- Added mempool-electrs to APP_PORTS mapping
- ElectrumX container creation in first-boot
- Podman doctor/fix/uptime skills added

Also includes: session persistence, identity management, LND transactions,
ElectrumX status UI, nostr-provider improvements, Web5 enhancements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-16 12:58:35 +00:00
parent 4e54b8bd4d
commit 367b483a72
49 changed files with 6180 additions and 495 deletions

View File

@ -0,0 +1,156 @@
---
name: podman-doctor
description: >
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
port mappings, network connectivity, health status, restart policies, and config consistency
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
Use when asked to "diagnose containers", "check podman", "why is app not working",
"container health check", "port not reachable", "audit containers", "podman status",
or when any container/app is misbehaving.
allowed-tools: Bash Read Glob Grep
---
# Podman Doctor — Container Infrastructure Diagnostics
Systematic diagnostic for Archipelago's Podman container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, and config drift across all layers.
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
## Workflow
### Step 1: Gather Runtime State
Run these on the server:
```bash
# All containers with status, ports, networks
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
# Check for port conflicts on known ports
sudo ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
```
### Step 2: Check Restart Policies
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
```bash
for c in $(sudo podman ps -a --format "{{.Names}}"); do
echo -n "$c: "
sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
done
```
**Red flag**: `no` or empty = container won't survive reboot.
### Step 3: Verify Port Mapping Consistency
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
**Layer 2 — Podman Runtime**: `sudo podman ps --format "{{.Names}}: {{.Ports}}"`
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
- `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS)
**Layer 4 — Frontend Routing**: Read `neode-ui/src/stores/appLauncher.ts``PORT_TO_APP_ID` map.
| Symptom | Root Cause |
|---------|-----------|
| App iframe shows 502/504 | Nginx proxies to wrong port, or container not running |
| App loads wrong content | Port collision — two containers on same host port |
| Works on port but not /app/ path | Missing nginx location block |
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
### Step 4: Network Connectivity Audit
```bash
# Networks and their containers
sudo podman network ls
sudo podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
```
**Must be on archy-net**: bitcoin-knots, lnd, electrs, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
### Step 5: Health Check Status
```bash
# Containers with health checks — are they passing?
for c in $(sudo podman ps --format "{{.Names}}"); do
health=$(sudo podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
echo "$c: $health"
fi
done
# Containers WITHOUT health checks (gap in monitoring)
for c in $(sudo podman ps --format "{{.Names}}"); do
hc=$(sudo podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
echo "NO HEALTHCHECK: $c"
fi
done
```
### Step 6: Resource & Failure Analysis
```bash
# Resource usage
sudo podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
# Recent deaths (last 24h)
sudo podman events --filter event=died --since 24h 2>/dev/null | tail -20
# OOM kills
sudo podman ps -a --format "{{.Names}}" | while read c; do
oom=$(sudo podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
done
# Non-zero exits
sudo podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
```
### Step 7: Systemd Integration
```bash
systemctl is-active archipelago nginx
systemctl list-units --type=service | grep -i podman
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
```
### Step 8: Generate Report
Produce a structured report:
```
## Container Diagnostic Report
### Summary
- Total containers: X running, Y stopped, Z unhealthy
- Port conflicts: [list or "none"]
- Missing restart policies: [list or "none"]
- Network issues: [list or "none"]
- Health check gaps: [list]
### Critical Issues (fix immediately)
1. ...
### Warnings (fix soon)
1. ...
### Recommended Actions
1. ...
```
After diagnosis, suggest running `/podman-fix` for any issues found.
## Port Reference
See `references/port-map.md` for the canonical port assignment table across all 4 layers.

View File

@ -0,0 +1,55 @@
# Common Podman Failure Patterns
## Container Won't Start
| Error | Cause | Fix |
|-------|-------|-----|
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
| `permission denied` | Missing capability or read-only root | Check `get_app_capabilities()`, add tmpfs |
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
| `no such network` | Network missing | `podman network create archy-net` |
## Container Starts But App Unreachable
| Symptom | Check Layer | Fix |
|---------|------------|-----|
| Direct port works, /app/ doesn't | Nginx config | Add `/app/{id}/` location block |
| Neither works | Podman ports | `podman port NAME` — verify mapping exists |
| Port mapped but refused | Container logs | App crashing internally — check logs |
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
## Container Keeps Dying
| Pattern | Cause | Fix |
|---------|-------|-----|
| Exits immediately (code 1) | Config error | Check `podman logs NAME` |
| Dies after minutes | OOM killed | Increase `--memory` limit |
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
| Crash loop | Repeated crash | Fix root cause, don't just restart |
## Network Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
| Container-to-container timeout | Different networks | Put both on same network |
## Capability Reference
| Capability | Apps That Need It | Failure Mode |
|-----------|------------------|-------------|
| CHOWN | nextcloud, homeassistant, btcpay, jellyfin, portainer | Can't chown during setup |
| SETUID/SETGID | nextcloud, homeassistant, btcpay, jellyfin | Can't switch to service user |
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
## Read-Only Safe Apps
Only these 8 apps can run with `--read-only`: searxng, grafana, filebrowser, electrs, nostr-rs-relay, ollama, indeedhub
All others need writable root or will fail silently.

View File

@ -0,0 +1,71 @@
# Archipelago Canonical Port Map
All port assignments across the 4 configuration layers. When adding or debugging an app, every row must be consistent across all columns.
## Bitcoin Stack
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| bitcoin-knots | 8332, 8333 | 8332, 8333 | archy-net | /app/bitcoin-knots/ | 8332→bitcoin-knots |
| bitcoin-ui | 8334 | 80 | bridge | /app/bitcoin-ui/ | 8334→bitcoin-knots |
| electrs | 50001 | 50001 | archy-net | /app/electrs/ | 50001→electrs |
| lnd | 9735, 10009, 8080 | 9735, 10009, 8080 | archy-net | /app/lnd/ | 10009→lnd |
| lnd-ui (RTL) | 8081 | 80 | bridge | /app/lnd-ui/ | 8081→lnd |
## Lightning & Payment
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| btcpay-server | 23000 | 49392 | archy-net | /app/btcpay/ | 23000→btcpay-server |
| nbxplorer | 24444 | 32838 | archy-net | N/A (internal) | N/A |
| fedimint | 8173, 8174, 8175 | 8173, 8174, 8175 | archy-net | /app/fedimint/ | 8174→fedimint |
| fedimint-gateway | 8175 | 8175 | archy-net | /app/fedimint-gateway/ | 8175→fedimint-gateway |
## Explorer & Monitoring
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| mempool | 4080 | 8080 | archy-net | /app/mempool/ | 4080→mempool |
| grafana | 3000 | 3000 | bridge | /app/grafana/ | 3000→grafana (new tab) |
## Self-Hosted Apps
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| nextcloud | 8085 | 80 | bridge | /app/nextcloud/ | 8085→nextcloud |
| vaultwarden | 8082 | 80 | bridge | /app/vaultwarden/ | 8082→vaultwarden (new tab) |
| filebrowser | 8083 | 80 | bridge | /app/filebrowser/ | 8083→filebrowser |
| searxng | 8888 | 8080 | bridge | /app/searxng/ | 8888→searxng |
| photoprism | 2342 | 2342 | bridge | /app/photoprism/ | 2342→photoprism (new tab) |
| jellyfin | 8096 | 8096 | bridge | /app/jellyfin/ | 8096→jellyfin |
| homeassistant | 8123 | 8123 | bridge | /app/homeassistant/ | 8123→homeassistant (new tab) |
| ollama | 11434 | 11434 | archy-net | /app/ollama/ | 11434→ollama |
| open-webui | 3080 | 8080 | archy-net | /app/open-webui/ | 3080→open-webui |
## Nostr & Social
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| nostr-rs-relay | 7000 | 8080 | archy-net | /app/nostr-rs-relay/ | 7000→nostr-rs-relay |
| indeedhub | 3001 | 3000 | archy-net | /app/indeedhub/ | 3001→indeedhub |
## System
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|-----|-------------|-------------------|---------|------------|-------------|
| tailscale | 8240 | 8240 | host | /app/tailscale/ | N/A |
| nginx-proxy-manager | 81, 8443 | 81, 443 | bridge | N/A | 81→nginx-proxy-manager |
## Multi-Container Stacks
**Immich**: immich-server (2283), immich-postgres (internal 5432), immich-redis (internal 6379) — all on immich-net
**Penpot**: penpot-frontend (9001→80), penpot-backend, penpot-exporter, penpot-postgres, penpot-mailcatch — all on penpot-net
**Mempool**: mempool (4080→8080), mempool-db (internal 3306) — on archy-net
**BTCPay**: btcpay-server (23000→49392), nbxplorer (24444→32838), btcpay-postgres (internal 5432) — on archy-net
## Key Notes
- **archy-net apps** resolve each other by container name (e.g., `bitcoin-knots:8332`)
- **bridge apps** are standalone — access services via host IP/port
- **host network** (tailscale only) — shares host namespace, no port mapping
- **New tab apps**: btcpay (23000), grafana (3000), vaultwarden (8082), photoprism (2342), homeassistant (8123) — X-Frame-Options blocks iframe

View File

@ -0,0 +1,219 @@
---
name: podman-fix
description: >
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
fix network connectivity, add missing restart policies, and resolve config drift.
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
"app won't start", "fix podman", "repair container", "container down", or after /podman-doctor
identifies issues to fix.
allowed-tools: Bash Read Edit Write Glob Grep
---
# Podman Fix — Container Remediation
Targeted fix workflow for Podman container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
## Fix Procedures
### Fix 1: Container Not Running
```bash
# Check why it stopped
sudo podman logs --tail 50 CONTAINER_NAME
sudo podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
# If clean exit or crash — just restart
sudo podman start CONTAINER_NAME
# If corrupt state — remove and recreate
sudo podman rm -f CONTAINER_NAME
# Then recreate using the install flow (trigger from UI or re-run creation command)
```
**If container keeps crashing**: check logs for the actual error. Common causes:
- Missing config file → check if volume mount has the config
- Wrong permissions → `chown -R` the data directory
- Dependency not ready → start dependency first, wait, then start this container
### Fix 2: Missing Restart Policy
The most common uptime killer. Fix for ALL containers at once:
```bash
# Fix a single container
sudo podman update --restart unless-stopped CONTAINER_NAME
# Fix ALL containers that have no restart policy
for c in $(sudo podman ps -a --format "{{.Names}}"); do
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
echo "Fixing restart policy for: $c"
sudo podman update --restart unless-stopped "$c"
fi
done
```
**Also update the Rust source** so new installs get it right:
- Check `core/archipelago/src/api/rpc/package.rs` `get_app_config()` for the app
- Ensure `--restart` flag is in the podman run args
### Fix 3: Port Mapping Issues
#### Port conflict (address already in use)
```bash
# Find what's using the port
sudo ss -tlnp | grep :PORT_NUMBER
# If it's another container, either change one's port or stop the conflicting one
sudo podman stop CONFLICTING_CONTAINER
# If it's a host process
sudo kill PID # or stop the service
```
#### Port not mapped (container running but port unreachable)
```bash
# Check current port mappings
sudo podman port CONTAINER_NAME
# Can't add ports to running container — must recreate
sudo podman stop CONTAINER_NAME
sudo podman rm CONTAINER_NAME
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
```
#### Nginx proxy missing or wrong
Read and fix the nginx config:
- HTTP: `image-recipe/configs/nginx-archipelago.conf`
- HTTPS: `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`
Add a location block:
```nginx
location /app/APP_ID/ {
proxy_pass http://127.0.0.1:HOST_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Hide X-Frame-Options so it works in our iframe
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
```
After editing nginx config, deploy and reload:
```bash
# On server
sudo nginx -t && sudo systemctl reload nginx
```
#### Frontend routing missing
Edit `neode-ui/src/stores/appLauncher.ts`:
- Add entry to `PORT_TO_APP_ID` map
- If app blocks iframes, add port to the new-tab list in `resolveAppIdFromUrl()`
### Fix 4: Network Issues
#### Container not on archy-net (can't resolve other containers)
```bash
# Connect to archy-net without recreating
sudo podman network connect archy-net CONTAINER_NAME
# Verify
sudo podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
```
#### archy-net doesn't exist
```bash
sudo podman network create archy-net
# Then reconnect all containers that need it
```
#### DNS not working inside container
```bash
# Test DNS from inside container
sudo podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
sudo podman exec CONTAINER_NAME ping -c1 bitcoin-knots
# If DNS fails, recreate container with explicit DNS
# Add --dns 1.1.1.1 to the podman run command
```
### Fix 5: Health Check Issues
#### Add missing health check to running container
Can't add to running container — must recreate with health check flags:
```bash
# Example for a web app
sudo podman run ... \
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
--health-start-period 60s \
IMAGE
```
#### Fix unhealthy container
```bash
# See what the health check is actually running
sudo podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
# Run the health check manually to see the error
sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
# Common fixes:
# - curl not installed in container → use wget or nc instead
# - Wrong port in health check → fix the check command
# - App takes too long to start → increase --health-start-period
```
### Fix 6: Permission/Capability Issues
```bash
# Check what capabilities container has
sudo podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
# If missing required caps, must recreate with correct --cap-add flags
# Refer to the capability reference in /podman-doctor references
# Fix data directory permissions
sudo chown -R 1000:1000 /var/lib/archipelago/APP_NAME/
```
### Fix 7: Full Config Consistency Fix
When port map is inconsistent across layers, fix ALL layers:
1. **Decide the correct port** (usually what's in package.rs)
2. **Fix Podman**: recreate container with correct `-p` flags
3. **Fix Nginx**: update location block's `proxy_pass` port
4. **Fix Frontend**: update `PORT_TO_APP_ID` in appLauncher.ts
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
## After Fixing
Always verify the fix:
```bash
# Container running?
sudo podman ps --filter name=CONTAINER_NAME
# Port reachable?
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
# Via nginx proxy?
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
# Health check passing?
sudo podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
```
Run `/podman-doctor` again to confirm all issues are resolved.

View File

@ -0,0 +1,309 @@
---
name: podman-uptime
description: >
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
restart policies, creates health check monitors, and configures auto-recovery for all
containers. Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
"survive reboot", or to harden container reliability.
allowed-tools: Bash Read Edit Write Glob Grep
---
# Podman Uptime — Container Reliability Guardian
Ensures every Archipelago container survives reboots, recovers from crashes, and stays healthy. Sets up the three layers of uptime defense: restart policies, systemd watchdog, and health-based auto-recovery.
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
## Layer 1: Restart Policies (Survive Reboots)
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
### Audit and fix all containers
```bash
# Audit
for c in $(sudo podman ps -a --format "{{.Names}}"); do
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
echo "$c: $policy"
done
# Fix any with "no" or empty policy
for c in $(sudo podman ps -a --format "{{.Names}}"); do
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
echo "Fixing: $c"
sudo podman update --restart unless-stopped "$c"
fi
done
```
### Ensure podman auto-starts containers on boot
```bash
# Enable podman-restart service (restarts containers with restart policy on boot)
sudo systemctl enable podman-restart.service 2>/dev/null || true
# If podman-restart doesn't exist, create it
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
[Unit]
Description=Podman Start All Containers With Restart Policy
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable podman-restart.service
```
## Layer 2: Systemd Watchdog (Detect and Recover)
Create a systemd timer that checks container health every 2 minutes and restarts unhealthy or stopped containers.
### Create the watchdog script
```bash
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
#!/bin/bash
# Archipelago Container Watchdog
# Checks all containers and restarts any that are stopped or unhealthy
LOG_TAG="container-watchdog"
# Restart any stopped containers that should be running (have restart policy)
for c in $(sudo podman ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}"); do
logger -t "$LOG_TAG" "Restarting stopped container: $c"
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
done
# Restart unhealthy containers
for c in $(sudo podman ps --filter health=unhealthy --format "{{.Names}}"); do
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
sudo podman restart "$c" 2>&1 | logger -t "$LOG_TAG"
done
# Check for containers in "created" state (never started)
for c in $(sudo podman ps -a --filter status=created --format "{{.Names}}"); do
logger -t "$LOG_TAG" "Starting created container: $c"
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
done
SCRIPT
sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh
```
### Create the systemd timer
```bash
# Service unit
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
[Unit]
Description=Archipelago Container Watchdog
After=podman-restart.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
EOF
# Timer unit — runs every 2 minutes
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.timer
[Unit]
Description=Run Archipelago Container Watchdog every 2 minutes
[Timer]
OnBootSec=120
OnUnitActiveSec=120
AccuracySec=30
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now archipelago-watchdog.timer
```
### Verify watchdog is running
```bash
sudo systemctl status archipelago-watchdog.timer
sudo systemctl list-timers | grep archipelago
# Check watchdog logs
sudo journalctl -t container-watchdog --since "1 hour ago" --no-pager
```
## Layer 3: Dependency-Aware Startup Order
Some containers depend on others. The watchdog handles restarts, but initial boot order matters.
### Create ordered startup script
```bash
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
#!/bin/bash
# Ordered container startup for Archipelago
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
LOG_TAG="ordered-start"
wait_for_container() {
local name=$1
local max_wait=${2:-60}
local waited=0
while [ $waited -lt $max_wait ]; do
status=$(sudo podman inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
if [ "$status" = "true" ]; then
logger -t "$LOG_TAG" "$name is running"
return 0
fi
sleep 5
waited=$((waited + 5))
done
logger -t "$LOG_TAG" "WARNING: $name not running after ${max_wait}s"
return 1
}
# Tier 0: Infrastructure
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
sudo podman start tailscale 2>/dev/null
# Tier 1: Bitcoin (foundation)
logger -t "$LOG_TAG" "Starting Tier 1: Bitcoin"
sudo podman start bitcoin-knots 2>/dev/null
wait_for_container bitcoin-knots 120
# Tier 2: Bitcoin-dependent services
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin-dependent"
sudo podman start electrs 2>/dev/null
sudo podman start lnd 2>/dev/null
wait_for_container electrs 90
wait_for_container lnd 90
# Tier 3: Services depending on Tier 2
logger -t "$LOG_TAG" "Starting Tier 3: Second-order dependencies"
sudo podman start mempool-db 2>/dev/null
sleep 5
sudo podman start mempool 2>/dev/null
sudo podman start nbxplorer 2>/dev/null
sleep 10
sudo podman start btcpay-server 2>/dev/null
sudo podman start btcpay-postgres 2>/dev/null
# Tier 4: Independent apps (start all remaining)
logger -t "$LOG_TAG" "Starting Tier 4: Independent apps"
sudo podman start --all 2>/dev/null
# Tier 5: UI containers (need parent apps running first)
logger -t "$LOG_TAG" "Starting Tier 5: UI containers"
sudo podman start bitcoin-ui 2>/dev/null
sudo podman start lnd-ui 2>/dev/null
logger -t "$LOG_TAG" "Startup sequence complete"
SCRIPT
sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh
```
### Wire into boot sequence
```bash
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
[Unit]
Description=Archipelago Ordered Container Startup
After=network-online.target podman.service
Wants=network-online.target
Before=archipelago.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
RemainAfterExit=yes
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable archipelago-containers.service
```
## Verification Checklist
After setting up all 3 layers, verify:
```bash
echo "=== Layer 1: Restart Policies ==="
for c in $(sudo podman ps -a --format "{{.Names}}"); do
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
echo " $c: $policy"
done
echo ""
echo "=== Layer 2: Watchdog Timer ==="
sudo systemctl is-active archipelago-watchdog.timer
sudo systemctl list-timers | grep archipelago
echo ""
echo "=== Layer 3: Boot Services ==="
sudo systemctl is-enabled podman-restart.service 2>/dev/null || echo "podman-restart: not found"
sudo systemctl is-enabled archipelago-containers.service 2>/dev/null || echo "ordered-start: not found"
sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchdog: not found"
echo ""
echo "=== Container Health Summary ==="
total=$(sudo podman ps -a --format "{{.Names}}" | wc -l)
running=$(sudo podman ps --format "{{.Names}}" | wc -l)
stopped=$((total - running))
unhealthy=$(sudo podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
```
## Reboot Test
The ultimate uptime test — reboot the server and verify everything comes back:
```bash
# Before reboot: record running containers
sudo podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
# Reboot
sudo reboot
# After reboot (wait ~3 minutes, then SSH back in):
sudo podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
# Compare
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
# Should show no differences
```
## Monitoring
Check uptime status anytime:
```bash
# Quick status
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
# Watchdog activity
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
# Container events (starts, stops, deaths)
sudo podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
```
## Integration
- Run `/podman-doctor` first to identify issues
- Run `/podman-fix` for specific container repairs
- Run `/podman-uptime` to set up permanent reliability infrastructure
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot

View File

@ -118,6 +118,7 @@ impl ApiHandler {
// WebSocket upgrade — validate session before upgrading
if method == Method::GET && path == "/ws/db" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;

View File

@ -176,23 +176,58 @@ impl RpcHandler {
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
// Determine lan_address based on container name
// Map container name to its UI port (lan_address)
let lan_address = match name {
"bitcoin-knots" => Some("http://localhost:8334"),
"lnd" => Some("http://localhost:8081"),
"bitcoin-knots" | "bitcoin-ui" => Some("http://localhost:8334"),
"lnd" | "archy-lnd-ui" => Some("http://localhost:8081"),
"tailscale" => Some("http://localhost:8240"),
"homeassistant" => Some("http://localhost:8123"),
"archy-mempool-web" | "mempool" => Some("http://localhost:4080"),
"btcpay-server" => Some("http://localhost:23000"),
"grafana" => Some("http://localhost:3000"),
"searxng" => Some("http://localhost:8888"),
"ollama" => Some("http://localhost:11434"),
"onlyoffice" => Some("http://localhost:9980"),
"penpot" => Some("http://localhost:9001"),
"nextcloud" => Some("http://localhost:8085"),
"vaultwarden" => Some("http://localhost:8082"),
"jellyfin" => Some("http://localhost:8096"),
"photoprism" => Some("http://localhost:2342"),
"immich_server" | "immich" => Some("http://localhost:2283"),
"filebrowser" => Some("http://localhost:8083"),
"nginx-proxy-manager" => Some("http://localhost:81"),
"portainer" => Some("http://localhost:9000"),
"uptime-kuma" => Some("http://localhost:3001"),
"fedimint" => Some("http://localhost:8175"),
"fedimint-gateway" => Some("http://localhost:8176"),
"nostr-rs-relay" => Some("http://localhost:18081"),
"indeedhub" => Some("http://localhost:7777"),
"dwn" => Some("http://localhost:3100"),
"endurain" => Some("http://localhost:8080"),
"electrs" | "archy-electrs-ui" => Some("http://localhost:50002"),
_ => None,
};
// Parse ports from podman JSON (field is "host_port" in snake_case)
let ports: Vec<String> = c.get("Ports")
.and_then(|v| v.as_array())
.map(|a| {
a.iter().filter_map(|p| {
let host = p.get("host_port").and_then(|v| v.as_u64())?;
let container = p.get("container_port").and_then(|v| v.as_u64())?;
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
}).collect()
})
.unwrap_or_default();
serde_json::json!({
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
"name": name,
"state": mapped_state,
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
).unwrap_or_default(),
"ports": ports,
"lan_address": lan_address,
})
})

View File

@ -1,7 +1,7 @@
//! RPC handlers for multi-identity management.
use super::RpcHandler;
use crate::identity_manager::{IdentityManager, IdentityPurpose};
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
use crate::network::did_dht;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
@ -43,6 +43,7 @@ impl RpcHandler {
"is_default": is_default,
"nostr_pubkey": id.nostr_pubkey,
"nostr_npub": id.nostr_npub,
"profile": id.profile,
})
})
.collect();
@ -650,6 +651,94 @@ impl RpcHandler {
}))
}
/// Update profile metadata for an identity.
pub(super) async fn handle_identity_update_profile(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = params.get("id").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let profile = IdentityProfile {
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
};
let manager = IdentityManager::new(&self.config.data_dir).await?;
manager.update_profile(id, profile).await?;
Ok(serde_json::json!({ "ok": true }))
}
/// Publish kind 0 (metadata) profile to the local Nostr relay.
pub(super) async fn handle_identity_publish_profile(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = params.get("id").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let relay_url = params.get("relay")
.and_then(|v| v.as_str())
.unwrap_or("ws://localhost:18081");
let manager = IdentityManager::new(&self.config.data_dir).await?;
let event_id = manager.publish_profile(id, relay_url).await?;
Ok(serde_json::json!({
"event_id": event_id,
"relay": relay_url,
"published": true,
}))
}
/// Export private keys for an identity — REQUIRES password verification.
pub(super) async fn handle_identity_export_keys(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
validate_identity_id(id)?;
// Verify password against auth system
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Invalid password");
}
let manager = IdentityManager::new(&self.config.data_dir).await?;
let keys = manager.export_keys(id).await?;
let record = manager.get(id).await?;
Ok(serde_json::json!({
"id": record.id,
"name": record.name,
"pubkey": record.pubkey_hex,
"did": record.did,
"nostr_pubkey": record.nostr_pubkey,
"nostr_npub": record.nostr_npub,
"ed25519_secret_hex": keys["ed25519_secret_hex"],
"nostr_secret_hex": keys["nostr_secret_hex"],
"nostr_nsec": keys["nostr_nsec"],
}))
}
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
pub(super) async fn handle_identity_dht_status(
&self,

View File

@ -674,6 +674,129 @@ impl RpcHandler {
"broadcast": true,
}))
}
/// List on-chain transactions from LND.
/// Returns all transactions, with incoming (amount > 0) flagged.
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get("https://127.0.0.1:8080/v1/transactions")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse transactions response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
}
let empty_vec = vec![];
let raw_txs = body
.get("transactions")
.and_then(|v| v.as_array())
.unwrap_or(&empty_vec);
let mut transactions: Vec<serde_json::Value> = Vec::new();
for tx in raw_txs {
let amount: i64 = tx
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
.unwrap_or(0);
let num_confirmations: i64 = tx
.get("num_confirmations")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let tx_hash = tx
.get("tx_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let time_stamp: i64 = tx
.get("time_stamp")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
.unwrap_or(0);
let total_fees: i64 = tx
.get("total_fees")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
.unwrap_or(0);
let dest_addresses: Vec<String> = tx
.get("dest_addresses")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let label = tx
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let block_height: i64 = tx
.get("block_height")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let direction = if amount > 0 { "incoming" } else { "outgoing" };
transactions.push(serde_json::json!({
"tx_hash": tx_hash,
"amount_sats": amount.abs(),
"direction": direction,
"num_confirmations": num_confirmations,
"time_stamp": time_stamp,
"total_fees": total_fees,
"dest_addresses": dest_addresses,
"label": label,
"block_height": block_height,
}));
}
// Sort by timestamp descending (most recent first)
transactions.sort_by(|a, b| {
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
tb.cmp(&ta)
});
let incoming_pending: usize = transactions
.iter()
.filter(|t| {
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
})
.count();
Ok(serde_json::json!({
"transactions": transactions,
"incoming_pending_count": incoming_pending,
}))
}
}
// Channel types

View File

@ -35,7 +35,7 @@ use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore};
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL};
use crate::state::StateManager;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
@ -221,12 +221,30 @@ impl RpcHandler {
// Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
let mut new_session_cookies: Option<(String, String)> = None; // (session, csrf) if auto-restored
if !is_unauthenticated {
let authenticated = match &session_token {
let mut authenticated = match &session_token {
Some(token) => self.session_store.validate(token).await,
None => false,
};
// If session invalid, try remember-me token to auto-restore session
if !authenticated {
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
if crate::session::SessionStore::validate_remember_token(&remember) {
// Auto-create a new session from the remember-me token
let new_token = self.session_store.create().await;
let new_csrf = generate_csrf_token();
tracing::info!("Auto-restored session from remember-me token");
new_session_cookies = Some((new_token, new_csrf));
authenticated = true;
}
}
}
if !authenticated {
let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" };
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
@ -269,7 +287,8 @@ impl RpcHandler {
}
// CSRF protection: validate X-CSRF-Token header for authenticated methods
if !is_unauthenticated {
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response)
if !is_unauthenticated && new_session_cookies.is_none() {
let csrf_cookie = extract_csrf_cookie(&parts.headers);
let csrf_header = parts
.headers
@ -280,6 +299,12 @@ impl RpcHandler {
match (&csrf_cookie, &csrf_header) {
(Some(cookie), Some(header)) if cookie == header => { /* valid */ }
_ => {
tracing::warn!(
method = %rpc_req.method,
has_cookie = csrf_cookie.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF mismatch — rejecting RPC call"
);
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
@ -445,6 +470,7 @@ impl RpcHandler {
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
@ -461,6 +487,9 @@ impl RpcHandler {
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
"identity.dht-status" => self.handle_identity_dht_status(params).await,
"identity.update-profile" => self.handle_identity_update_profile(params).await,
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
"identity.export-keys" => self.handle_identity_export_keys(params).await,
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
@ -778,6 +807,7 @@ impl RpcHandler {
// No 2FA: create a full session immediately
let token = self.session_store.create().await;
let csrf_token = generate_csrf_token();
let remember_token = self.session_store.create_remember_token();
response.headers_mut().append(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
@ -790,6 +820,13 @@ impl RpcHandler {
.parse()
.unwrap(),
);
// Remember-me: HMAC-signed, survives backend restarts (30-day TTL)
response.headers_mut().append(
"Set-Cookie",
format!("remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
.parse()
.unwrap(),
);
}
}
@ -844,6 +881,23 @@ impl RpcHandler {
);
}
// If session was auto-restored from remember-me, set new cookies on the response
if let Some((new_session, new_csrf)) = new_session_cookies {
let suffix = self.cookie_suffix();
response.headers_mut().append(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", new_session, suffix)
.parse()
.unwrap(),
);
response.headers_mut().append(
"Set-Cookie",
format!("csrf_token={}; SameSite=Strict; Path=/{}", new_csrf, suffix)
.parse()
.unwrap(),
);
}
Ok(response)
}
@ -864,13 +918,14 @@ fn generate_csrf_token() -> String {
hex::encode(bytes)
}
/// Extract the csrf_token cookie value from headers.
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
/// Extract a named cookie value from headers.
fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
let prefix = format!("{}=", name);
for value in headers.get_all("cookie") {
if let Ok(s) = value.to_str() {
for part in s.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix("csrf_token=") {
if let Some(val) = part.strip_prefix(&prefix) {
let val = val.trim();
if !val.is_empty() {
return Some(val.to_string());
@ -882,6 +937,11 @@ fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
None
}
/// Extract the csrf_token cookie value from headers.
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
extract_cookie(headers, "csrf_token")
}
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
headers

View File

@ -58,13 +58,13 @@ impl RpcHandler {
})
};
let has_bitcoin = is_running(&["bitcoin-knots", "bitcoin-core", "bitcoin"]);
let has_electrs = is_running(&["mempool-electrs", "electrs"]);
let has_electrumx = is_running(&["electrumx", "mempool-electrs", "electrs"]);
has_lnd = is_running(&["lnd"]);
match package_id {
"mempool-electrs" | "electrs" if !has_bitcoin => {
"electrumx" | "mempool-electrs" | "electrs" if !has_bitcoin => {
return Err(anyhow::anyhow!(
"Electrs requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
));
}
"lnd" if !has_bitcoin => {
@ -77,10 +77,10 @@ impl RpcHandler {
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
));
}
"mempool" | "mempool-web" if !has_bitcoin || !has_electrs => {
"mempool" | "mempool-web" if !has_bitcoin || !has_electrumx => {
let mut missing = vec![];
if !has_bitcoin { missing.push("Bitcoin Knots"); }
if !has_electrs { missing.push("Electrs"); }
if !has_electrumx { missing.push("ElectrumX"); }
return Err(anyhow::anyhow!(
"Mempool requires {} to be running. Please install and start {} first.",
missing.join(" and "),
@ -179,8 +179,11 @@ impl RpcHandler {
debug!("Using local image: {}", docker_image);
}
// Normalize container name: "electrs" alias -> "mempool-electrs"
let container_name = if package_id == "electrs" { "mempool-electrs" } else { package_id };
// Normalize container name: legacy "electrs"/"mempool-electrs" aliases -> "electrumx"
let container_name = match package_id {
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
};
// Create and start container with security constraints
let mut run_args = vec![
@ -234,7 +237,7 @@ impl RpcHandler {
package_id,
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
| "lnd"
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db" | "archy-nbxplorer" | "nbxplorer"
| "fedimint" | "fedimint-gateway"
);
@ -669,10 +672,10 @@ printtoconsole=1\n";
vec![format!("archy-{}", package_id)]
} else {
let order: &[&str] = match package_id {
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
"penpot" | "penpot-frontend" => &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"],
_ => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
_ => &["archy-mempool-db", "mysql-mempool", "electrumx", "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));
@ -1114,6 +1117,7 @@ async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
let patterns: Vec<String> = match package_id {
"mempool" | "mempool-web" => {
vec![
"electrumx".into(),
"mempool-electrs".into(),
"mempool-api".into(),
"archy-mempool-api".into(),
@ -1160,6 +1164,7 @@ fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
"mempool" | "mempool-web" => vec![
format!("{}/mempool", base),
format!("{}/mysql-mempool", base),
format!("{}/electrumx", base),
format!("{}/mempool-electrs", base),
],
"fedimint" => vec![format!("{}/fedimint", base), format!("{}/fedimint-gateway", base)],
@ -1300,6 +1305,7 @@ fn is_readonly_compatible(app_id: &str) -> bool {
"searxng"
| "grafana"
| "filebrowser"
| "electrumx"
| "mempool-electrs"
| "electrs"
| "nostr-rs-relay"
@ -1332,8 +1338,8 @@ fn get_health_check_args(app_id: &str) -> Vec<String> {
"curl -sf http://localhost:8080/ || exit 1",
"30s", "3",
),
"mempool-electrs" | "electrs" => (
"curl -sf http://localhost:50001/ || exit 1",
"electrumx" | "mempool-electrs" | "electrs" => (
"curl -sf http://localhost:8000/ || exit 1",
"60s", "3",
),
"nextcloud" => (
@ -1420,7 +1426,7 @@ fn get_memory_limit(app_id: &str) -> &'static str {
"ollama" => "4g",
// Medium apps
"lnd" => "512m",
"mempool-electrs" | "electrs" => "1g",
"electrumx" | "mempool-electrs" | "electrs" => "1g",
"nextcloud" => "1g",
"immich_server" | "immich" => "1g",
"btcpay-server" | "btcpayserver" => "1g",
@ -1507,7 +1513,7 @@ fn get_app_config(
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=mempool-electrs".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", host_ip),
@ -1523,26 +1529,20 @@ fn get_app_config(
None,
None,
),
"mempool-electrs" | "electrs" => {
"electrumx" | "mempool-electrs" | "electrs" => {
// Detect which bitcoin container is running for archy-net DNS resolution
let bitcoin_host = detect_bitcoin_container_name();
(
vec!["50001:50001".to_string()],
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
vec![],
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!("DAEMON_URL=http://archipelago:archipelago123@{}:8332/", bitcoin_host),
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
],
None,
None,
Some(vec![
"--daemon-rpc-addr".to_string(),
format!("{}:8332", bitcoin_host),
"--cookie".to_string(),
"archipelago:archipelago123".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" => (

View File

@ -592,8 +592,8 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data and restart.
/// Preserves container images and node_key (hardware identity).
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
pub(super) async fn handle_system_factory_reset(
&self,
params: Option<serde_json::Value>,
@ -609,53 +609,67 @@ impl RpcHandler {
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
}
tracing::warn!("Factory reset initiated — wiping user data");
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir;
// Stop all running containers
// 1. Stop and remove ALL containers (force)
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
if let Ok(containers) = client.list_containers().await {
for c in &containers {
tracing::info!("Factory reset: removing container {}", c.name);
let _ = client.stop_container(&c.name).await;
let _ = client.remove_container(&c.name).await;
}
}
// Delete user data (preserving node_key and container images)
let files_to_remove = [
"user.json",
"onboarding.json",
"peers.json",
"server-name",
];
for f in &files_to_remove {
let path = data_dir.join(f);
if path.exists() {
let _ = tokio::fs::remove_file(&path).await;
// 2. Remove all container images
tracing::info!("Factory reset: pruning all container images");
let _ = tokio::process::Command::new("sudo")
.args(["-u", "archipelago", "podman", "rmi", "--all", "--force"])
.output()
.await;
// 3. Prune volumes and build cache
let _ = tokio::process::Command::new("sudo")
.args(["-u", "archipelago", "podman", "volume", "prune", "-f"])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["-u", "archipelago", "podman", "system", "prune", "-af"])
.output()
.await;
// 4. Wipe the entire data directory contents
// Delete everything inside data_dir, then recreate the empty dir.
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip the tor directory (managed by system debian-tor user)
if name_str == "tor" {
continue;
}
tracing::info!("Factory reset: removing {}", path.display());
if path.is_dir() {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
}
}
let dirs_to_remove = [
"identities",
"credentials",
"did-cache",
"dwn",
];
for d in &dirs_to_remove {
let path = data_dir.join(d);
if path.exists() {
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
// Clear all sessions
// 5. Clear all sessions
self.session_store.invalidate_all_except("").await;
tracing::warn!("Factory reset complete — restarting service");
tracing::warn!("Factory reset complete — all data wiped, restarting service");
// Restart the service via systemd
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = std::process::Command::new("sudo")
.args(["systemctl", "restart", "archipelago"])
.spawn();

View File

@ -139,9 +139,9 @@ impl DockerPackageScanner {
} else if app_id == "indeedhub" {
debug!("Using IndeedHub: http://localhost:7777");
Some("http://localhost:7777".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");
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
// ElectrumX UI runs on host at port 50002
debug!("Using electrumx-ui for electrumx: http://localhost:50002");
Some("http://localhost:50002".to_string())
} else {
// Extract port from the main container
@ -240,7 +240,7 @@ fn get_app_tier(app_id: &str) -> &'static str {
// Core: required for basic Bitcoin node
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core",
"lnd" => "core",
"mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" => "core",
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core",
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
@ -329,11 +329,11 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/mempool/mempool".to_string(),
tier: "",
},
"mempool-electrs" | "electrs" => AppMetadata {
title: "Electrs".to_string(),
description: "Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients.".to_string(),
"electrumx" | "mempool-electrs" | "electrs" => AppMetadata {
title: "ElectrumX".to_string(),
description: "ElectrumX server — full Electrum protocol indexer for Bitcoin. Powers Mempool and Electrum wallets.".to_string(),
icon: "/assets/img/app-icons/electrs.svg".to_string(),
repo: "https://github.com/romanz/electrs".to_string(),
repo: "https://github.com/spesmilo/electrumx".to_string(),
tier: "",
},
"ollama" => AppMetadata {

View File

@ -1,4 +1,4 @@
//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
//! ElectrumX sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
use anyhow::{Context, Result};
use serde::Serialize;
@ -6,12 +6,12 @@ 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 ELECTRUMX_HOST: &str = "127.0.0.1";
const ELECTRUMX_PORT: u16 = 50001;
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
const ELECTRS_DATA_DIR: &str = "/var/lib/archipelago/mempool-electrs";
// Approximate final index size in bytes for mainnet with --lightmode (~35GB)
const ESTIMATED_FULL_INDEX_BYTES: f64 = 35_000_000_000.0;
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
// Approximate final index size in bytes for mainnet (~55GB for ElectrumX full index)
const ESTIMATED_FULL_INDEX_BYTES: f64 = 55_000_000_000.0;
/// Build Bitcoin RPC Basic auth header from env vars.
/// Falls back to cookie auth file if env vars are not set.
@ -61,10 +61,10 @@ fn format_bytes(bytes: u64) -> 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")?;
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
fn electrumx_indexed_height() -> Result<u64> {
let mut stream = TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT))
.context("Failed to connect to ElectrumX")?;
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.context("set_read_timeout")?;
@ -83,11 +83,11 @@ fn electrs_indexed_height() -> Result<u64> {
reader.read_line(&mut line)?;
let line = line.trim();
if line.is_empty() {
anyhow::bail!("Empty response from electrs");
anyhow::bail!("Empty response from ElectrumX");
}
let json: serde_json::Value = serde_json::from_str(line)?;
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N}
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N, hex: ...}
let height = json
.get("result")
.and_then(|r| r.as_u64())
@ -96,7 +96,13 @@ fn electrs_indexed_height() -> Result<u64> {
.and_then(|r| r.get("block_height"))
.and_then(|h| h.as_u64())
})
.context("Missing height in electrs response")?;
.or_else(|| {
// ElectrumX returns {"result": {"height": N, "hex": "..."}}
json.get("result")
.and_then(|r| r.get("height"))
.and_then(|h| h.as_u64())
})
.context("Missing height in ElectrumX response")?;
Ok(height)
}
@ -130,10 +136,10 @@ async fn bitcoin_network_height() -> Result<u64> {
Ok(height)
}
/// Get electrs sync status. Runs blocking electrs call in spawn_blocking.
/// Get ElectrumX sync status. Runs blocking ElectrumX call in spawn_blocking.
pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
// Get index data size (non-blocking, fast filesystem stat)
let data_bytes = dir_size_bytes(ELECTRS_DATA_DIR);
let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR);
let index_size = if data_bytes > 0 {
Some(format_bytes(data_bytes))
} else {
@ -154,10 +160,10 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
}
};
let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await {
let indexed_height = match tokio::task::spawn_blocking(electrumx_indexed_height).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
// Electrs doesn't listen on 50001 until indexing completes (can take hours)
// ElectrumX may not be ready on 50001 during initial sync
let err_msg = e.to_string();
let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") {
// Estimate progress from data directory size
@ -170,12 +176,12 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
(
"indexing".to_string(),
Some(format!(
"Building index ({} / ~35 GB estimated). Electrum RPC will be available when complete.",
"Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.",
size_str
)),
)
} else {
("error".to_string(), Some(format!("Electrs: {}", e)))
("error".to_string(), Some(format!("ElectrumX: {}", e)))
};
// Use estimated progress when indexing
let progress_pct = if status == "indexing" && data_bytes > 0 {

View File

@ -46,7 +46,7 @@ fn container_tier(name: &str) -> StartupTier {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => StartupTier::CoreInfra,
// Tier 2: Dependent services
"lnd" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService,
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => StartupTier::DependentService,
// Tier 4: Frontend/UI
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"

View File

@ -48,6 +48,28 @@ pub struct IdentityRecord {
pub nostr_pubkey: Option<String>,
/// Nostr public key in bech32 npub format (NIP-19)
pub nostr_npub: Option<String>,
/// Nostr profile metadata (NIP-01 kind 0)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<IdentityProfile>,
}
/// Nostr profile metadata fields (NIP-01 kind 0 + NIP-24 extra fields).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nip05: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lud16: Option<String>,
}
/// On-disk format for identity storage (includes secret key bytes).
@ -64,6 +86,9 @@ struct IdentityFile {
nostr_secret_hex: Option<String>,
#[serde(default)]
nostr_pubkey_hex: Option<String>,
/// Nostr profile metadata
#[serde(default)]
profile: Option<IdentityProfile>,
}
pub struct IdentityManager {
@ -123,6 +148,7 @@ impl IdentityManager {
created_at: created_at.clone(),
nostr_secret_hex: None,
nostr_pubkey_hex: None,
profile: None,
};
let file_path = self.identities_dir.join(format!("{}.json", id));
@ -345,6 +371,77 @@ impl IdentityManager {
.context("NIP-44 decryption failed")
}
/// Update the profile metadata for an identity.
pub async fn update_profile(&self, id: &str, profile: IdentityProfile) -> Result<()> {
let file_path = self.identities_dir.join(format!("{}.json", id));
if !file_path.exists() {
return Err(anyhow::anyhow!("Identity not found: {}", id));
}
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
let mut file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
file.profile = Some(profile);
let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?;
fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?;
Ok(())
}
/// Publish kind 0 (metadata) event to a Nostr relay.
pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result<String> {
let record = self.get(id).await?;
let keys = self.load_nostr_keys(id).await?;
let profile = record.profile.unwrap_or_default();
// Build kind 0 content JSON (NIP-01 + NIP-24)
let mut content = serde_json::Map::new();
content.insert("name".to_string(), serde_json::json!(record.name));
if let Some(v) = &profile.display_name { content.insert("display_name".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.about { content.insert("about".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.picture { content.insert("picture".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.banner { content.insert("banner".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.website { content.insert("website".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.nip05 { content.insert("nip05".to_string(), serde_json::json!(v)); }
if let Some(v) = &profile.lud16 { content.insert("lud16".to_string(), serde_json::json!(v)); }
let content_str = serde_json::to_string(&content).context("Failed to serialize profile content")?;
let client = nostr_sdk::Client::new(keys);
client.add_relay(relay_url).await.context("Failed to add relay")?;
client.connect().await;
let builder = nostr_sdk::prelude::EventBuilder::new(
nostr_sdk::prelude::Kind::Metadata,
&content_str,
);
let output = client.send_event_builder(builder).await.context("Failed to publish profile")?;
client.disconnect().await;
Ok(output.id().to_hex())
}
/// Export all keys for an identity (SENSITIVE — only call after password verification).
pub async fn export_keys(&self, id: &str) -> Result<serde_json::Value> {
let file_path = self.identities_dir.join(format!("{}.json", id));
if !file_path.exists() {
return Err(anyhow::anyhow!("Identity not found: {}", id));
}
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
let ed25519_secret_hex = hex::encode(&file.secret_key);
let nostr_nsec = file.nostr_secret_hex.as_ref().and_then(|h| {
nostr_sdk::SecretKey::from_hex(h)
.ok()
.and_then(|sk| sk.to_bech32().ok())
});
Ok(serde_json::json!({
"ed25519_secret_hex": ed25519_secret_hex,
"nostr_secret_hex": file.nostr_secret_hex,
"nostr_nsec": nostr_nsec,
}))
}
// --- internal helpers ---
}
@ -395,6 +492,7 @@ impl IdentityManager {
created_at: file.created_at,
nostr_pubkey: file.nostr_pubkey_hex,
nostr_npub,
profile: file.profile,
})
}

View File

@ -1,15 +1,22 @@
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use zeroize::Zeroize;
type HmacSha256 = Hmac<Sha256>;
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
const MAX_TOTP_ATTEMPTS: u8 = 5;
const MAX_CONCURRENT_SESSIONS: usize = 5;
const MAX_CONCURRENT_SESSIONS: usize = 20;
const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json";
const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret";
pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days
#[derive(Clone)]
enum SessionType {
@ -38,13 +45,106 @@ struct Session {
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
persist_path: PathBuf,
}
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
#[derive(serde::Serialize, serde::Deserialize)]
struct PersistedSession {
hash_hex: String,
created_at: u64, // Unix timestamp
last_activity: u64, // Unix timestamp
}
impl SessionStore {
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
let persist_path = PathBuf::from(SESSIONS_FILE);
let sessions = Self::load_from_disk(&persist_path);
let count = sessions.len();
if count > 0 {
tracing::info!("Restored {} sessions from disk", count);
}
Self {
sessions: Arc::new(RwLock::new(sessions)),
persist_path,
}
}
/// Load persisted sessions from disk (only Full sessions).
fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> {
let mut map = HashMap::new();
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(_) => return map,
};
let persisted: Vec<PersistedSession> = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to parse sessions file: {}", e);
return map;
}
};
let now_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for p in persisted {
// Skip expired sessions
if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL {
continue;
}
let hash = match hex::decode(&p.hash_hex) {
Ok(h) if h.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&h);
arr
}
_ => continue,
};
let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at);
let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity);
map.insert(hash, Session {
created_at,
last_activity,
session_type: SessionType::Full,
});
}
map
}
/// Save all Full sessions to disk. Called after mutations.
fn save_to_disk_sync(sessions: &HashMap<[u8; 32], Session>, path: &Path) {
let persisted: Vec<PersistedSession> = sessions
.iter()
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
.map(|(hash, s)| PersistedSession {
hash_hex: hex::encode(hash),
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
})
.collect();
if let Ok(json) = serde_json::to_string(&persisted) {
let _ = std::fs::write(path, json);
}
}
/// Async wrapper for save — spawns to avoid blocking RPC.
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
let persisted: Vec<PersistedSession> = sessions
.iter()
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
.map(|(hash, s)| PersistedSession {
hash_hex: hex::encode(hash),
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
})
.collect();
let path = self.persist_path.clone();
tokio::spawn(async move {
if let Ok(json) = serde_json::to_string(&persisted) {
let _ = tokio::fs::write(path, json).await;
}
});
}
/// Create a full (authenticated) session. Returns the plaintext token.
@ -63,6 +163,9 @@ impl SessionStore {
let mut sessions = self.sessions.write().await;
self.evict_if_over_limit(&mut sessions);
sessions.insert(hash, session);
// Sync save — must complete before returning the token to the client.
// Async save risks losing the session if the process is killed (e.g., deploy restart).
Self::save_to_disk_sync(&sessions, &self.persist_path);
token
}
@ -140,12 +243,15 @@ impl SessionStore {
let now = SystemTime::now();
session.created_at = now;
session.last_activity = now;
Self::save_to_disk_sync(&sessions, &self.persist_path);
}
}
pub async fn remove(&self, token: &str) {
let hash = hash_token(token);
self.sessions.write().await.remove(&hash);
let mut sessions = self.sessions.write().await;
sessions.remove(&hash);
Self::save_to_disk_sync(&sessions, &self.persist_path);
}
/// Invalidate all sessions except the one matching the given token.
@ -154,6 +260,7 @@ impl SessionStore {
let keep_hash = hash_token(keep_token);
let mut sessions = self.sessions.write().await;
sessions.retain(|hash, _| *hash == keep_hash);
Self::save_to_disk_sync(&sessions, &self.persist_path);
}
/// Rotate a session: invalidate the old token and create a new one.
@ -175,6 +282,7 @@ impl SessionStore {
session_type: SessionType::Full,
},
);
Self::save_to_disk_sync(&sessions, &self.persist_path);
new_token
}
@ -222,6 +330,78 @@ impl SessionStore {
})
.count()
}
// ── Remember-me token ──────────────────────────────────────────────
// HMAC-signed token that survives backend restarts. Secret is on disk.
// Format: "timestamp_hex:hmac_hex"
/// Create a remember-me token. Returns the cookie value.
pub fn create_remember_token(&self) -> String {
let secret = Self::load_or_create_remember_secret();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts_hex = hex::encode(now.to_be_bytes());
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
mac.update(format!("remember:{}", ts_hex).as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
format!("{}:{}", ts_hex, sig)
}
/// Validate a remember-me token. Returns true if valid and not expired.
pub fn validate_remember_token(token: &str) -> bool {
let secret = match std::fs::read(REMEMBER_SECRET_FILE) {
Ok(s) if s.len() == 32 => s,
_ => return false,
};
let parts: Vec<&str> = token.splitn(2, ':').collect();
if parts.len() != 2 {
return false;
}
let ts_hex = parts[0];
let sig_hex = parts[1];
// Verify HMAC
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(format!("remember:{}", ts_hex).as_bytes());
let expected_sig = match hex::decode(sig_hex) {
Ok(s) => s,
Err(_) => return false,
};
if mac.verify_slice(&expected_sig).is_err() {
return false;
}
// Check expiry
let ts_bytes = match hex::decode(ts_hex) {
Ok(b) if b.len() == 8 => {
let mut arr = [0u8; 8];
arr.copy_from_slice(&b);
u64::from_be_bytes(arr)
}
_ => return false,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(ts_bytes) < REMEMBER_TTL
}
fn load_or_create_remember_secret() -> Vec<u8> {
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
if secret.len() == 32 {
return secret;
}
}
let secret: [u8; 32] = rand::random();
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
secret.to_vec()
}
}
fn hash_token(token: &str) -> [u8; 32] {

View File

@ -66,6 +66,41 @@ impl PodmanClient {
}
}
/// Map container name to its UI launch URL
fn lan_address_for(name: &str) -> Option<String> {
let url = match name {
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
"tailscale" => "http://localhost:8240",
"homeassistant" => "http://localhost:8123",
"archy-mempool-web" | "mempool" => "http://localhost:4080",
"btcpay-server" => "http://localhost:23000",
"grafana" => "http://localhost:3000",
"searxng" => "http://localhost:8888",
"ollama" => "http://localhost:11434",
"onlyoffice" => "http://localhost:9980",
"penpot" => "http://localhost:9001",
"nextcloud" => "http://localhost:8085",
"vaultwarden" => "http://localhost:8082",
"jellyfin" => "http://localhost:8096",
"photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283",
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:81",
"portainer" => "http://localhost:9000",
"uptime-kuma" => "http://localhost:3001",
"fedimint" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
"nostr-rs-relay" => "http://localhost:18081",
"indeedhub" => "http://localhost:7777",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
_ => return None,
};
Some(url.to_string())
}
fn podman_async(&self) -> TokioCommand {
// Always use sudo podman to access system-wide containers
let mut cmd = TokioCommand::new("sudo");
@ -348,6 +383,7 @@ impl PodmanClient {
vec![]
};
let lan_address = Self::lan_address_for(&name);
result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(),
name,
@ -355,7 +391,7 @@ impl PodmanClient {
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(),
ports,
lan_address: None, // Set by docker_packages scanner
lan_address,
});
}
} else {
@ -365,7 +401,7 @@ impl PodmanClient {
if line.trim().is_empty() {
continue;
}
if let Ok(container) = serde_json::from_str::<serde_json::Value>(line) {
// Handle both Names as array and Names as string
let name = if let Some(names_array) = container["Names"].as_array() {
@ -373,7 +409,7 @@ impl PodmanClient {
} else {
container["Names"].as_str().unwrap_or("").to_string()
};
// Parse ports from the Ports array
let ports = if let Some(ports_array) = container["Ports"].as_array() {
ports_array.iter().filter_map(|port| {
@ -390,7 +426,8 @@ impl PodmanClient {
} else {
vec![]
};
let lan_address = Self::lan_address_for(&name);
result.push(ContainerStatus {
id: container["Id"].as_str().unwrap_or("").to_string(),
name,
@ -398,7 +435,7 @@ impl PodmanClient {
image: container["Image"].as_str().unwrap_or("").to_string(),
created: container["Created"].as_str().unwrap_or("").to_string(),
ports,
lan_address: None, // Set by docker_packages scanner
lan_address,
});
}
}

View File

@ -2,6 +2,7 @@ FROM docker.io/library/nginx:alpine
# Copy the static UI
COPY index.html /usr/share/nginx/html/
COPY tailwind.css /usr/share/nginx/html/
# Create assets directories first
RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \

View File

@ -7,7 +7,7 @@
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Bitcoin Knots - Archipelago</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="tailwind.css">
<style>
* {
margin: 0;
@ -35,7 +35,7 @@
.bg-layer {
position: absolute;
inset: 0;
background-image: url('/assets/img/bg-network.jpg');
background-image: url('assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@ -337,7 +337,7 @@
<div class="flex-shrink-0">
<div class="logo-gradient-border">
<img
src="/assets/img/app-icons/bitcoin-knots.webp"
src="assets/img/app-icons/bitcoin-knots.webp"
alt="Bitcoin Knots"
class="w-16 h-16"
style="object-fit: contain;"
@ -591,7 +591,7 @@
console.log('[Bitcoin UI] Script loaded, initializing...');
// RPC Configuration - Use local Nginx proxy within container
const RPC_ENDPOINT = '/bitcoin-rpc/';
const RPC_ENDPOINT = 'bitcoin-rpc/';
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
// Make RPC call to Bitcoin node via local proxy

View File

@ -0,0 +1,210 @@
/* Tailwind CSS utilities — manually extracted for bitcoin-ui */
/* Replaces cdn.tailwindcss.com to comply with CSP script-src 'self' */
*, ::before, ::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
img, svg { display: block; vertical-align: middle; max-width: 100%; height: auto; }
button { cursor: pointer; background-color: transparent; font-family: inherit; font-size: 100%; line-height: inherit; color: inherit; margin: 0; padding: 0; }
h1, h2 { font-size: inherit; font-weight: inherit; }
/* Position */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.inset-0 { inset: 0; }
/* Display */
.hidden { display: none; }
.flex { display: flex; }
.grid { display: grid; }
/* Flex */
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-1 { flex: 1 1 0%; }
.flex-shrink-0 { flex-shrink: 0; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
/* Gap */
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
/* Grid */
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* Width */
.w-3 { width: 0.75rem; }
.w-4 { width: 1rem; }
.w-5 { width: 1.25rem; }
.w-6 { width: 1.5rem; }
.w-8 { width: 2rem; }
.w-12 { width: 3rem; }
.w-16 { width: 4rem; }
.w-full { width: 100%; }
/* Height */
.h-1 { height: 0.25rem; }
.h-2 { height: 0.5rem; }
.h-3 { height: 0.75rem; }
.h-4 { height: 1rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-8 { height: 2rem; }
.h-12 { height: 3rem; }
.h-16 { height: 4rem; }
.h-full { height: 100%; }
/* Min/Max */
.min-w-0 { min-width: 0px; }
.max-w-2xl { max-width: 42rem; }
.max-w-4xl { max-width: 56rem; }
.max-h-\[80vh\] { max-height: 80vh; }
/* Padding */
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
/* Margin */
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
/* Typography — Size */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
/* Typography — Weight */
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.text-center { text-align: center; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Typography — Color */
.text-white { color: #ffffff; }
.text-white\/40 { color: rgba(255, 255, 255, 0.4); }
.text-white\/50 { color: rgba(255, 255, 255, 0.5); }
.text-white\/60 { color: rgba(255, 255, 255, 0.6); }
.text-white\/70 { color: rgba(255, 255, 255, 0.7); }
.text-white\/80 { color: rgba(255, 255, 255, 0.8); }
.text-green-400 { color: #4ade80; }
.text-green-500 { color: #22c55e; }
.text-orange-400 { color: #fb923c; }
.text-orange-500 { color: #f97316; }
.text-red-400 { color: #f87171; }
.text-yellow-400 { color: #facc15; }
/* Backgrounds */
.bg-green-400 { background-color: #4ade80; }
.bg-green-500 { background-color: #22c55e; }
.bg-orange-400 { background-color: #fb923c; }
.bg-orange-500 { background-color: #f97316; }
.bg-red-400 { background-color: #f87171; }
.bg-orange-500\/20 { background-color: rgba(249, 115, 22, 0.2); }
.bg-white\/5 { background-color: rgba(255, 255, 255, 0.05); }
.bg-white\/10 { background-color: rgba(255, 255, 255, 0.1); }
.bg-black\/20 { background-color: rgba(0, 0, 0, 0.2); }
.bg-black\/40 { background-color: rgba(0, 0, 0, 0.4); }
.bg-black\/60 { background-color: rgba(0, 0, 0, 0.6); }
.bg-black\/80 { background-color: rgba(0, 0, 0, 0.8); }
/* Gradients */
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)); }
.from-orange-500 { --tw-gradient-from: #f97316; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(249, 115, 22, 0)); }
.from-orange-400 { --tw-gradient-from: #fb923c; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(251, 146, 60, 0)); }
.to-yellow-400 { --tw-gradient-to: #facc15; }
.to-orange-600 { --tw-gradient-to: #ea580c; }
/* Border */
.border { border-width: 1px; }
.border-white\/10 { border-color: rgba(255, 255, 255, 0.1); }
.border-white\/20 { border-color: rgba(255, 255, 255, 0.2); }
.border-orange-500\/30 { border-color: rgba(249, 115, 22, 0.3); }
/* Border Radius */
.rounded { border-radius: 0.25rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
.rounded-2xl { border-radius: 1rem; }
.rounded-full { border-radius: 9999px; }
/* Overflow */
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
/* Opacity */
.opacity-0 { opacity: 0; }
.opacity-75 { opacity: 0.75; }
.opacity-100 { opacity: 1; }
/* Z-Index */
.z-10 { z-index: 10; }
.z-50 { z-index: 50; }
/* Spacing utilities */
.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; }
.space-y-3 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.75rem; }
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
/* Backdrop filter */
.backdrop-blur-sm { -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
.backdrop-blur-md { -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.backdrop-blur-xl { -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); }
/* Transitions */
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.duration-300 { transition-duration: 300ms; }
.duration-500 { transition-duration: 500ms; }
/* Text wrapping */
.whitespace-pre-wrap { white-space: pre-wrap; }
.break-all { word-break: break-all; }
/* Cursor */
.cursor-pointer { cursor: pointer; }
/* Responsive: md (768px+) */
@media (min-width: 768px) {
.md\:flex-row { flex-direction: row; }
.md\:items-center { align-items: center; }
.md\:gap-4 { gap: 1rem; }
.md\:gap-6 { gap: 1.5rem; }
.md\:w-auto { width: auto; }
.md\:mt-0 { margin-top: 0; }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Responsive: lg (1024px+) */
@media (min-width: 1024px) {
.lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

View File

@ -1,6 +1,7 @@
FROM docker.io/library/nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY qrcode.js /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN mkdir -p /usr/share/nginx/html/assets/img

View File

@ -4,7 +4,7 @@
<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>
<title>ElectrumX - Archipelago</title>
<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; }
@ -69,6 +69,28 @@
.md-flex-row { flex-direction: row; }
.md-grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
}
/* Connection details */
.conn-tabs { display: flex; background: rgba(255,255,255,0.08); border-radius: 0.5rem; overflow: hidden; margin-bottom: 1.5rem; }
.conn-tab { flex: 1; padding: 0.625rem 1rem; text-align: center; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; color: rgba(255,255,255,0.5); border: none; background: none; }
.conn-tab.active { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.conn-tab:hover:not(.active) { color: rgba(255,255,255,0.8); }
.conn-layout { display: flex; flex-direction: column; gap: 1.5rem; }
@media (min-width: 640px) { .conn-layout { flex-direction: row; } }
.qr-box { flex-shrink: 0; width: 196px; background: white; border-radius: 0.75rem; padding: 0.75rem; display: flex; align-items: center; justify-content: center; }
.qr-box img { width: 100%; height: auto; display: block; image-rendering: pixelated; }
.conn-fields { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.45); margin-bottom: 0.25rem; }
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.9375rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.copy-btn { padding: 0.625rem 0.75rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.copy-btn.copied { color: #4ade80; }
.field-row-split { display: flex; gap: 0.75rem; }
.field-row-split > div { flex: 1; }
.conn-disabled { text-align: center; padding: 2rem 1rem; }
.help-text { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(255,255,255,0.08); font-size: 0.875rem; color: rgba(255,255,255,0.5); line-height: 1.5; }
.version-text { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
</style>
</head>
<body>
@ -76,6 +98,7 @@
<div class="overlay"></div>
<div class="container">
<!-- Header -->
<div class="glass-card p-6 mb-6">
<div class="flex flex-col md-flex-row items-center gap-4">
<div class="icon-box flex-shrink-0">
@ -84,8 +107,11 @@
</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 class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white">ElectrumX</h1>
<span class="version-text">v1.18.0</span>
</div>
<p class="text-white-70">Bitcoin Electrum server for wallet connections</p>
</div>
<div class="info-card flex items-center gap-3">
<div id="statusDot" class="status-dot bg-yellow"></div>
@ -97,7 +123,8 @@
</div>
</div>
<div class="glass-card p-6">
<!-- Sync Status -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start gap-4 mb-4">
<div class="icon-box-sm flex-shrink-0">
<svg id="syncIcon" style="width:1.5rem;height:1.5rem;color:#f97316" class="animate-spin-slow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -139,17 +166,215 @@
</div>
</div>
</div>
<!-- Connection Details -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-1">Connect Your Wallet</h2>
<p class="text-white-70 text-sm mb-4" id="connSubtitle">Use the following details to connect your wallet or application to ElectrumX.</p>
<div class="conn-tabs">
<button class="conn-tab active" id="tabLocal" onclick="switchTab('local')">Local Network</button>
<button class="conn-tab" id="tabTor" onclick="switchTab('tor')">Tor</button>
</div>
<!-- Local Network Tab -->
<div id="panelLocal" class="conn-layout">
<div class="qr-box" id="qrLocalBox"></div>
<div class="conn-fields">
<div>
<div class="field-label">Address</div>
<div class="field-row">
<span class="field-value" id="localAddress">-</span>
<button class="copy-btn" onclick="copyField('localAddress', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div class="field-row-split">
<div>
<div class="field-label">Port</div>
<div class="field-row">
<span class="field-value">50001</span>
<button class="copy-btn" onclick="copyText('50001', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">SSL</div>
<div class="field-row">
<span class="field-value">Disabled</span>
<button class="copy-btn" onclick="copyText('Disabled', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Tor Tab -->
<div id="panelTor" style="display:none">
<div id="torAvailable" class="conn-layout" style="display:none">
<div class="qr-box" id="qrTorBox"></div>
<div class="conn-fields">
<div>
<div class="field-label">Onion Address</div>
<div class="field-row">
<span class="field-value" id="torAddress" style="font-size:0.75rem">-</span>
<button class="copy-btn" onclick="copyField('torAddress', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div class="field-row-split">
<div>
<div class="field-label">Port</div>
<div class="field-row">
<span class="field-value">50001</span>
<button class="copy-btn" onclick="copyText('50001', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">SSL</div>
<div class="field-row">
<span class="field-value">Disabled</span>
<button class="copy-btn" onclick="copyText('Disabled', this)" title="Copy">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div id="torUnavailable" class="conn-disabled">
<svg style="width:2.5rem;height:2.5rem;color:rgba(255,255,255,0.25);margin:0 auto 0.75rem" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<p class="text-white-70" style="font-size:0.9375rem">Tor hidden service not configured for ElectrumX.</p>
<p class="text-white-60 text-sm" style="margin-top:0.375rem">Enable Tor for ElectrumX in Settings to connect remotely.</p>
</div>
</div>
<div class="help-text">
Connect using <strong style="color:rgba(255,255,255,0.8)">Sparrow Wallet</strong>, <strong style="color:rgba(255,255,255,0.8)">Electrum</strong>, or any Electrum-protocol compatible wallet. Set the server to the address and port shown above with SSL disabled.
</div>
</div>
</div>
<script src="qrcode.js"></script>
<script>
var currentTab = 'local';
var torOnion = null;
function renderQR(containerId, text) {
var container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
try {
var qr = qrcode(0, 'M');
qr.addData(text);
qr.make();
container.innerHTML = qr.createImgTag(5, 0);
} catch(e) {
container.innerHTML = '<div style="color:#999;font-size:12px;text-align:center;padding:2rem">QR unavailable</div>';
}
}
function switchTab(tab) {
currentTab = tab;
document.getElementById('tabLocal').classList.toggle('active', tab === 'local');
document.getElementById('tabTor').classList.toggle('active', tab === 'tor');
document.getElementById('panelLocal').style.display = tab === 'local' ? '' : 'none';
document.getElementById('panelTor').style.display = tab === 'tor' ? '' : 'none';
}
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
function flashCopied(btn) {
btn.classList.add('copied');
var orig = btn.innerHTML;
btn.innerHTML = CHECK_SVG;
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = orig;
}, 1500);
}
function copyField(id, btn) {
var text = document.getElementById(id).textContent.trim();
if (!text || text === '-') return;
navigator.clipboard.writeText(text).then(function() { flashCopied(btn); });
}
function copyText(text, btn) {
navigator.clipboard.writeText(text).then(function() { flashCopied(btn); });
}
function copyConnStr(type) {
var id = type === 'tor' ? 'torConnStr' : 'localConnStr';
var btn = type === 'tor' ? document.querySelector('#torAvailable .conn-string-copy') : document.getElementById('localCopyAll');
var text = document.getElementById(id).textContent.trim();
if (!text || text === '-') return;
navigator.clipboard.writeText(text).then(function() {
btn.classList.add('copied');
var orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function() {
btn.classList.remove('copied');
btn.textContent = orig;
}, 1500);
});
}
function updateConnectionInfo() {
var host = window.location.hostname;
document.getElementById('localAddress').textContent = host;
renderQR('qrLocalBox', host + ':50001:t');
if (torOnion) {
document.getElementById('torAvailable').style.display = '';
document.getElementById('torUnavailable').style.display = 'none';
document.getElementById('torAddress').textContent = torOnion;
renderQR('qrTorBox', torOnion + ':50001:t');
} else {
document.getElementById('torAvailable').style.display = 'none';
document.getElementById('torUnavailable').style.display = '';
}
}
async function fetchTorInfo() {
try {
var resp = await fetch('/rpc/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ method: 'tor.list-services', params: {} })
});
var data = await resp.json();
if (data.result && data.result.services) {
var svc = data.result.services.find(function(s) {
return s.name === 'electrumx' || s.name === 'electrs' || s.local_port === 50001;
});
if (svc && svc.onion_address) {
torOnion = svc.onion_address;
updateConnectionInfo();
}
}
} catch(e) {}
}
async function updateStatus() {
try {
const resp = await fetch('electrs-status');
const data = await resp.json();
var resp = await fetch('electrs-status');
var data = await resp.json();
const indexedH = data.indexed_height ?? 0;
const networkH = data.network_height ?? 0;
const pct = data.progress_pct ?? 0;
var indexedH = data.indexed_height || 0;
var networkH = data.network_height || 0;
var pct = data.progress_pct || 0;
document.getElementById('indexedHeight').textContent = indexedH > 0 ? indexedH.toLocaleString() : (data.status === 'indexing' ? 'Building...' : '-');
document.getElementById('networkHeight').textContent = networkH > 0 ? networkH.toLocaleString() : '-';
@ -159,35 +384,39 @@
document.getElementById('syncPercentage').textContent = pct > 0 ? pct.toFixed(1) + '%' : '0%';
document.getElementById('syncProgressBar').style.width = Math.max(pct, 0.5) + '%';
const statusText = document.getElementById('syncStatusText');
const statusDot = document.getElementById('statusDot');
const syncIcon = document.getElementById('syncIcon');
var statusTextEl = document.getElementById('syncStatusText');
var statusDot = document.getElementById('statusDot');
var syncIcon = document.getElementById('syncIcon');
if (data.status === 'indexing') {
statusText.textContent = data.error || 'Building index...';
statusText.style.color = '#fbbf24';
statusTextEl.textContent = data.error || 'Building index...';
statusTextEl.style.color = '#fbbf24';
statusDot.className = 'status-dot bg-amber animate-pulse';
document.getElementById('statusText').textContent = 'Indexing';
syncIcon.classList.add('animate-spin-slow');
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
} else if (data.status === 'error') {
statusText.textContent = data.error || 'Unknown error';
statusText.style.color = '#f87171';
statusTextEl.textContent = data.error || 'Unknown error';
statusTextEl.style.color = '#f87171';
statusDot.className = 'status-dot bg-red';
document.getElementById('statusText').textContent = 'Error';
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
} else if (data.status === 'synced') {
statusText.textContent = 'Fully synchronized with the network';
statusText.style.color = '#4ade80';
statusTextEl.textContent = 'Fully synchronized with the network';
statusTextEl.style.color = '#4ade80';
statusDot.className = 'status-dot bg-green';
document.getElementById('statusText').textContent = 'Synced';
syncIcon.classList.remove('animate-spin-slow');
syncIcon.style.color = '#4ade80';
document.getElementById('connSubtitle').textContent = 'Use the following details to connect your wallet or application to ElectrumX.';
} else {
const remaining = networkH - indexedH;
statusText.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
statusText.style.color = '#fb923c';
var remaining = networkH - indexedH;
statusTextEl.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
statusTextEl.style.color = '#fb923c';
statusDot.className = 'status-dot bg-yellow';
document.getElementById('statusText').textContent = 'Syncing';
syncIcon.classList.add('animate-spin-slow');
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
}
} catch (e) {
document.getElementById('syncStatusText').textContent = 'Unable to fetch status: ' + e.message;
@ -196,6 +425,8 @@
}
updateStatus();
updateConnectionInfo();
fetchTorInfo();
setInterval(updateStatus, 5000);
</script>
</body>

2297
docker/electrs-ui/qrcode.js Normal file

File diff suppressed because it is too large Load Diff

71
docs/MASTER_PLAN.md Normal file
View File

@ -0,0 +1,71 @@
# MASTER PLAN
> Archipelago project task tracking and roadmap.
## Roadmap
| ID | Title | Priority | Status | Dependencies |
|----|-------|----------|--------|--------------|
| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - |
| **TASK-2** | **Roll incoming-tx into deploy & ISO** | **P2** | PLANNED | - |
| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - |
## Active Work
### BUG-1: Random logout / CSRF mismatch (PLANNED)
**Priority**: P0 — Critical
**Status**: PLANNED (2026-03-15)
Sessions expire unexpectedly during normal use. Backend sessions now persist to disk (`/var/lib/archipelago/sessions.json`) but CSRF token mismatch (403) still causes logouts. Need to investigate CSRF token lifecycle and fix the mismatch between cookie and header values.
**Root cause analysis so far**:
- Sessions were purely in-memory — fixed with disk persistence
- CSRF validation compares cookie value vs `X-CSRF-Token` header — both present but don't match
- Log: `403 CSRF mismatch — rejecting RPC call ... has_cookie=true has_header=true`
- Possible cause: cookie value rotated (e.g., new login in another tab) but frontend cached old value
**Key files**:
- `core/archipelago/src/session.rs` — session store (now persisted)
- `core/archipelago/src/api/rpc/mod.rs:273-307` — CSRF validation
- `neode-ui/src/api/rpc-client.ts:18-45` — frontend CSRF extraction from cookie
**Tasks**:
- [ ] Investigate CSRF token rotation — when/why cookie and header diverge
- [ ] Add logging to CSRF validation to capture actual cookie vs header values
- [ ] Consider returning CSRF token in response body (not just cookie) for explicit client storage
- [ ] Test multi-tab scenario where one tab's login rotates the CSRF token
- [ ] Verify session persistence survives deploys (second deploy test)
### TASK-2: Roll incoming-tx into deploy & ISO (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-16)
The incoming transactions feature (lnd.gettransactions RPC + wallet badge UI + auto-refresh) is working on .228. Roll changes into deploy-to-target.sh and build-auto-installer-iso.sh so fresh installs and deploys get it automatically. Do not break existing changes.
**Key files changed**:
- `core/archipelago/src/api/rpc/lnd.rs` — new `handle_lnd_gettransactions` method
- `core/archipelago/src/api/rpc/mod.rs` — registered `lnd.gettransactions` route
- `neode-ui/src/views/Web5.vue` — incoming tx badge, panel, auto-refresh polling
- `neode-ui/src/style.css` — incoming-tx-badge, incoming-tx-row, incoming-tx-slide classes
**Tasks**:
- [ ] Verify changes are already captured by existing deploy (backend build + frontend build)
- [ ] Ensure ISO build captures the updated Rust binary and frontend dist
- [ ] Test that no existing deploy/build logic is broken
### BUG-3: IndeedHub WebSocket spam in console (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-16)
`ws://localhost:7777/` connection refused fills browser console endlessly when IndeedHub is loaded in iframe. IndeedHub's compiled frontend bundle hardcodes `localhost` for WebSocket connections. When loaded from a remote host, `localhost` resolves to the user's machine, not the server.
**Root cause**: IndeedHub's Next.js build bakes `localhost:7777` into the WebSocket URL. The nginx WebSocket proxy at `/app/indeedhub/ws/` exists but is unused because IndeedHub loads via direct port 7777, not through the proxy path.
**Tasks**:
- [ ] Rebuild IndeedHub with `NEXT_PUBLIC_WS_URL` env var pointing to relative URL or actual server address
- [ ] Alternatively, configure IndeedHub to use relative WebSocket URLs (`/ws/` instead of `ws://localhost:7777/`)
- [ ] Test that WebSocket reconnection works after the fix
## Completed
<!-- Done tasks are moved here -->

View File

@ -590,7 +590,7 @@ docker.io/nicolasdorier/nbxplorer:2.6.0 nbxplorer.tar
docker.io/library/postgres:15-alpine postgres-btcpay.tar
docker.io/mempool/frontend:v2.5.0 mempool-frontend.tar
docker.io/mempool/backend:v2.5.0 mempool-backend.tar
docker.io/mempool/electrs:latest mempool-electrs.tar
docker.io/lukechilds/electrumx:v1.18.0 electrumx.tar
docker.io/library/mariadb:10.11 mariadb-mempool.tar
docker.io/fedimint/fedimintd:v0.10.0 fedimint.tar
docker.io/fedimint/gatewayd:v0.10.0 fedimint-gateway.tar
@ -735,7 +735,7 @@ import json
services = [
{"name": "archipelago", "local_port": 80, "enabled": True},
{"name": "bitcoin", "local_port": 8333, "enabled": True},
{"name": "electrs", "local_port": 50001, "enabled": True},
{"name": "electrumx", "local_port": 50001, "enabled": True},
{"name": "lnd", "local_port": 9735, "enabled": True},
{"name": "btcpay", "local_port": 23000, "enabled": True},
{"name": "mempool", "local_port": 4080, "enabled": True},
@ -757,7 +757,7 @@ HiddenServicePort 80 127.0.0.1:80
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
HiddenServicePort 8333 127.0.0.1:8333
HiddenServiceDir $TOR_DIR/hidden_service_electrs
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd

View File

@ -11,7 +11,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always;
# AIUI SPA (Chat mode iframe)
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
@ -487,7 +487,7 @@ server {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/electrs/ {
location /app/electrumx/ {
proxy_pass http://127.0.0.1:50002/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@ -675,7 +675,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always;
# AIUI SPA (Chat mode iframe)
location /aiui/ {

View File

@ -218,7 +218,7 @@ location /app/bitcoin-ui/ {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/electrs/ {
location /app/electrumx/ {
proxy_pass http://127.0.0.1:50002/;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.6f1usind3cc"
"revision": "0.tmc04bnmkho"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -29,6 +29,7 @@
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",
@ -1688,6 +1689,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
@ -2275,6 +2283,58 @@
"node": ">=18"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.2.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@ -2804,6 +2864,17 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2876,6 +2947,80 @@
"node": ">=18"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@ -4312,6 +4457,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -4480,6 +4635,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@ -4493,6 +4669,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -4515,6 +4701,18 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -4615,6 +4813,31 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -4622,6 +4845,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"dev": true,
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -4818,6 +5051,13 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"dev": true,
"license": "ISC"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -5135,6 +5375,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5872,6 +6127,41 @@
"dev": true,
"license": "MIT"
},
"node_modules/docker-modem": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
"integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
"readable-stream": "^3.5.0",
"split-ca": "^1.0.1",
"ssh2": "^1.15.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dockerode": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz",
"integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"@grpc/grpc-js": "^1.11.1",
"@grpc/proto-loader": "^0.7.13",
"docker-modem": "^5.0.6",
"protobufjs": "^7.3.2",
"tar-fs": "^2.1.4",
"uuid": "^10.0.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -5960,6 +6250,16 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@ -6512,6 +6812,13 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -6955,6 +7262,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -7791,6 +8119,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -7805,6 +8140,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -7988,6 +8330,13 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -8014,6 +8363,14 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -8159,6 +8516,16 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -8563,6 +8930,31 @@
"dev": true,
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -8577,6 +8969,17 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -8687,6 +9090,21 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -9485,6 +9903,31 @@
"node": ">=0.10.0"
}
},
"node_modules/split-ca": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -9523,6 +9966,16 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -9878,6 +10331,36 @@
"node": ">=14.0.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@ -10203,6 +10686,13 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -10526,6 +11016,20 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -11626,6 +12130,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@ -44,6 +44,7 @@
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,79 +1,160 @@
/**
* NIP-07 Nostr Provider Shim
* NIP-07 Nostr Provider Shim Archipelago
*
* Injected into proxied iframe apps via nginx sub_filter.
* Implements window.nostr interface (getPublicKey, signEvent)
* by communicating with the parent Archipelago frame via postMessage.
*
* Security: validates postMessage origin, never exposes secret key.
* Provides window.nostr (NIP-07) for iframe apps.
* Auto sign-in: does NIP-98 auth directly then reloads so the app
* picks up the valid session. Shows a loading overlay during auth.
*/
(function () {
'use strict';
// Only inject if we're inside an iframe
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
// Don't override existing NIP-07 extensions
if (window.nostr) return;
var pending = {};
var nextId = 1;
var pending = {}, nextId = 1;
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage(
{ type: 'nostr-request', id: id, method: method, params: params || {} },
'*'
);
// Timeout after 30 seconds
setTimeout(function () {
if (pending[id]) {
pending[id].reject(new Error('NIP-07 request timed out'));
delete pending[id];
}
}, 30000);
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
});
}
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'nostr-response') return;
var handler = pending[event.data.id];
if (!handler) return;
delete pending[event.data.id];
if (event.data.error) {
handler.reject(new Error(event.data.error));
} else {
handler.resolve(event.data.result);
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () {
return request('getPublicKey');
},
signEvent: function (event) {
return request('signEvent', { event: event });
},
getRelays: function () {
return request('getRelays');
},
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pubkey, plaintext) {
return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function (pubkey, ciphertext) {
return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
},
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
},
nip44: {
encrypt: function (pubkey, plaintext) {
return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function (pubkey, ciphertext) {
return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
},
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
},
};
// --- Loading Overlay ---
var overlay = null;
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Direct NIP-98 Auth ---
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
var apiBase = '/api';
var healthUrl = window.location.origin + apiBase + '/nostr-auth/health';
var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session';
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
return fetch(sessionUrl, {
method: 'POST',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
return res.json();
}).then(function (data) {
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
});
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
});
})();

View File

@ -6,13 +6,16 @@
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop near-black -->
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
<!-- Backdrop frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<!-- Main panel -->
<div
ref="modalRef"
@click.stop
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
@ -43,7 +46,7 @@
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
@ -61,15 +64,18 @@
v-for="identity in identities"
:key="identity.id"
type="button"
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
role="radio"
:aria-checked="selectedId === identity.id"
:aria-label="`Identity: ${identity.name}`"
class="w-full text-left p-3 rounded-lg transition-all duration-200"
:class="selectedId === identity.id
? 'bg-white/8 border-white/25'
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
? 'bg-white/10 ring-1 ring-white/20'
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
:class="avatarClasses(identity.purpose)"
>
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
@ -85,10 +91,10 @@
</div>
</div>
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
</div>
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
</div>
</div>
</button>
@ -104,8 +110,8 @@
:disabled="!selectedId || !hasNostrKey"
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="selectedId && hasNostrKey
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
: 'bg-white/3 border border-white/8 text-white/40'"
? 'bg-white/10 text-white hover:bg-white/15'
: 'bg-white/[0.03] text-white/40'"
>
Authenticate
</button>
@ -193,9 +199,9 @@ function truncateNpub(npub: string): string {
function avatarClasses(purpose: string): string {
switch (purpose) {
case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25'
case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25'
default: return 'bg-white/10 text-white/80 border-white/20'
case 'business': return 'bg-blue-500/15 text-blue-400'
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
default: return 'bg-white/10 text-white/80'
}
}
</script>

View File

@ -222,16 +222,18 @@ router.beforeEach(async (to, _from, next) => {
// If authenticated and visiting /login: show login immediately, validate in background.
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
if (to.path === '/login' && store.isAuthenticated) {
// Redirect back to intended page (from ?redirect= query) or default to home
const redirectTo = (to.query.redirect as string) || '/dashboard'
if (store.needsSessionValidation()) {
next()
checkSessionWithTimeout(store).then((valid) => {
if (valid) {
router.replace({ name: 'home' }).catch(() => {})
router.replace(redirectTo).catch(() => {})
}
})
return
}
next({ name: 'home' })
next(redirectTo)
return
}
next()
@ -245,7 +247,7 @@ router.beforeEach(async (to, _from, next) => {
next()
store.checkSession().then((valid) => {
if (!valid) {
router.replace('/login').catch(() => {})
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
}
})
return
@ -258,7 +260,7 @@ router.beforeEach(async (to, _from, next) => {
next()
return
}
next('/login')
next({ path: '/login', query: { redirect: to.fullPath } })
return
}

View File

@ -3,97 +3,59 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* These always open in a new tab. Other external sites load directly in the iframe. */
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
const IFRAME_BLOCKED_HOSTS: string[] = []
/** External site proxy paths disabled. External URLs load directly in the iframe
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
const EXTERNAL_PROXY_PATH: Record<string, string> = {}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
'23000', // BTCPay — X-Frame-Options: DENY
'3000', // Grafana — X-Frame-Options: deny
'2342', // PhotoPrism — X-Frame-Options: DENY
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
// External sites that block iframes
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
return true
}
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
if (
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
u.port === '3000' || // Grafana — X-Frame-Options: deny
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
) {
return true
}
return false
return NEW_TAB_PORTS.has(u.port)
} catch {
return false
}
}
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
const PORT_TO_PROXY: Record<string, string> = {
'81': '/app/nginx-proxy-manager/',
'3000': '/app/grafana/',
'3001': '/app/uptime-kuma/',
'8080': '/app/endurain/',
'8081': '/app/lnd/',
'8082': '/app/vaultwarden/',
'8083': '/app/filebrowser/',
'8085': '/app/nextcloud/',
'8096': '/app/jellyfin/',
'8123': '/app/homeassistant/',
'8240': '/app/tailscale/',
'8334': '/app/bitcoin-ui/',
'8888': '/app/searxng/',
'9000': '/app/portainer/',
'9001': '/app/penpot/',
'9980': '/app/onlyoffice/',
'11434': '/app/ollama/',
'2283': '/app/immich/',
'23000': '/app/btcpay/',
'2342': '/app/photoprism/',
'4080': '/app/mempool/',
'8175': '/app/fedimint/',
'8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/',
'7777': '/app/indeedhub/',
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'3000': 'grafana',
'3001': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'8082': 'vaultwarden',
'8083': 'filebrowser',
'8085': 'nextcloud',
'8096': 'jellyfin',
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9001': 'penpot',
'9980': 'onlyoffice',
'11434': 'ollama',
'2283': 'immich',
'23000': 'btcpay-server',
'2342': 'photoprism',
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'18081': 'nostr-rs-relay',
'7777': 'indeedhub',
'50002': 'electrumx',
}
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
* On HTTP, direct port URLs are used they avoid subpath routing issues
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
*/
function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// External sites proxied through nginx path-based locations
const extPath = EXTERNAL_PROXY_PATH[u.hostname]
if (extPath) {
return `${origin}${extPath}`
}
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
if (proxyPath && sameHost && needsProxy) {
return `${origin}${proxyPath}`
}
} catch {
/* ignore */
}
return url
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
@ -121,6 +83,8 @@ export interface NostrConsentRequest {
reject: () => void
}
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Open app in full-page session view (preferred — no iframe subpath issues) */
/** Active app in panel mode (store-based, no route change) */
const panelAppId = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
router.push({ name: 'app-session', params: { appId } })
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId } })
}
}
function closePanel() {
panelAppId.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
openSession(resolvedId)
return
}
// Apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
return
}
const embeddableUrl = toEmbeddableUrl(payload.url)
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = embeddableUrl
url.value = payload.url
title.value = payload.title
isOpen.value = true
}
@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
try {
const u = new URL(urlStr)
// Check port-based apps
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
if (u.port === port) {
return proxyPath.replace('/app/', '').replace(/\/$/, '')
}
}
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId
// Check external URLs
const EXTERNAL_APP_HOSTS: Record<string, string> = {
'botfights.net': 'botfights',
@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
open,
openSession,
close,
closePanel,
panelAppId,
showConsent,
consentRequest,
approveConsent,

View File

@ -16,6 +16,17 @@ export interface BundledApp {
lan_address?: string // Runtime launch URL from backend
}
/** Map bundled app ID to the podman container name(s) used for status matching.
* Some apps have a different container name than their app ID, or use a
* separate UI container (e.g., bitcoin-knots node bitcoin-ui web container). */
const CONTAINER_NAME_MAP: Record<string, string[]> = {
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
'lnd': ['lnd', 'archy-lnd-ui'],
'btcpay-server': ['btcpay-server'],
'mempool': ['archy-mempool-web'],
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
}
export const BUNDLED_APPS: BundledApp[] = [
{
id: 'bitcoin-knots',
@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'localhost/bitcoinknots/bitcoin:29',
description: 'Full Bitcoin node with additional features',
icon: '₿',
ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }],
ports: [{ host: 8334, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
category: 'bitcoin',
},
@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
description: 'Lightning Network Daemon for fast Bitcoin payments',
icon: '⚡',
ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }],
ports: [{ host: 8081, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
category: 'lightning',
},
@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [
category: 'home',
},
{
id: 'btcpayserver',
id: 'btcpay-server',
name: 'BTCPay Server',
image: 'docker.io/btcpayserver/btcpayserver:latest',
description: 'Self-hosted Bitcoin payment processor',
icon: '💳',
ports: [{ host: 23000, container: 23000 }],
ports: [{ host: 23000, container: 49392 }],
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
category: 'bitcoin',
},
@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/mempool/frontend:latest',
description: 'Bitcoin blockchain and mempool visualizer',
icon: '🔍',
ports: [{ host: 8080, container: 8080 }],
ports: [{ host: 4080, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
category: 'bitcoin',
},
{
id: 'nostr-rs-relay',
name: 'Nostr Relay (RS)',
image: 'docker.io/scsibug/nostr-rs-relay:latest',
description: 'Rust-based Nostr relay for decentralized social',
icon: '🦩',
ports: [{ host: 8008, container: 8080 }],
volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }],
category: 'other',
},
{
id: 'strfry',
name: 'Strfry Relay',
image: 'docker.io/hoytech/strfry:latest',
description: 'High-performance Nostr relay',
icon: '⚡',
ports: [{ host: 7777, container: 7777 }],
volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }],
category: 'other',
},
{
id: 'tailscale',
name: 'Tailscale VPN',
@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => {
healthStatus.value[appId] || 'unknown'
)
// Get container for a bundled app (matches by name)
// Get container for a bundled app (matches by explicit name map, then by exact name)
const getContainerForApp = computed(() => (appId: string) => {
return containers.value.find(c =>
c.name === appId ||
c.name.includes(appId) ||
c.name === `archipelago-${appId}` ||
c.name === `archipelago-${appId}-dev`
)
const nameList = CONTAINER_NAME_MAP[appId]
if (nameList) {
// Try each known container name in priority order
for (const n of nameList) {
const found = containers.value.find(c => c.name === n)
if (found) return found
}
}
// Fallback: exact match on app ID
return containers.value.find(c => c.name === appId)
})
// Check if an app is currently loading (starting/stopping)

View File

@ -462,6 +462,106 @@ input[type="radio"]:active + * {
transform: translateX(1rem);
}
/* Incoming Transactions badge */
.incoming-tx-badge {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
color: #4ade80;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
white-space: nowrap;
}
.incoming-tx-badge:hover {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.4);
transform: translateY(-1px);
}
.incoming-tx-ping {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #4ade80;
border-radius: 9999px;
animation: incoming-pulse 2s ease-in-out infinite;
}
@keyframes incoming-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
}
/* Incoming transaction row */
.incoming-tx-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.2s ease;
}
.incoming-tx-row:hover {
background: rgba(34, 197, 94, 0.08);
}
.incoming-tx-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.incoming-tx-icon-pending {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.incoming-tx-icon-confirmed {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
/* Slide-down transition for incoming tx panel */
.incoming-tx-slide-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.incoming-tx-slide-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.incoming-tx-slide-enter-from,
.incoming-tx-slide-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-8px);
margin-bottom: 0;
}
.incoming-tx-slide-enter-to,
.incoming-tx-slide-leave-from {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
/* BANNED: gradient-card, gradient-card-dark, gradient-button
Use .glass-card or .path-option-card for containers.
Use .glass-button for all buttons.

View File

@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
indeedhub: 'indeedhub',
electrs: 'mempool-electrs',
'mempool-electrs': 'mempool-electrs',
electrumx: 'electrumx',
electrs: 'electrumx',
'mempool-electrs': 'electrumx',
}
/** Backend may register under variant container names */
@ -536,7 +537,7 @@ const PACKAGE_ALIASES: Record<string, string[]> = {
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'mempool-web': ['archy-mempool-web'],
indeedhub: ['indeedhub-build_app_1'],
electrs: ['mempool-electrs', 'archy-electrs'],
electrumx: ['mempool-electrs', 'electrs', 'archy-electrs'],
}
function resolvePackageKey(routeId: string): string {

View File

@ -1,9 +1,9 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="displayMode === 'panel'">
<Teleport to="body" :disabled="isInlinePanel">
<div
:class="backdropClasses"
@click.self="goBack"
@click.self="handleBackdropClick"
>
<div
ref="sessionRef"
@ -178,12 +178,24 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
const props = defineProps<{
appIdProp?: string
}>()
const emit = defineEmits<{
close: []
}>()
/** True when rendered inline via store (panel mode), false when route-based */
const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
@ -214,6 +226,25 @@ function setMode(mode: DisplayMode) {
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
showModeMenu.value = false
// Switch from inline panel route-based overlay/fullscreen
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
router.push({ name: 'app-session', params: { appId: id } })
return
}
// Switch from route-based inline panel
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
launcher.panelAppId = id
})
return
}
// Enter fullscreen if selected
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
@ -222,49 +253,92 @@ function setMode(mode: DisplayMode) {
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
return 'app-session-backdrop-overlay'
}
return 'app-session-backdrop-panel'
if (isInlinePanel.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
if (isInlinePanel.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
return `${base} app-session-inpanel`
return `${base} app-session-overlay`
})
const appId = computed(() => route.params.appId as string)
const appId = computed(() => props.appIdProp || (route.params.appId as string))
const APP_URLS: Record<string, string> = {
// Container apps use nginx proxy paths (strips X-Frame-Options)
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334,
'bitcoin-ui': 8334,
'electrumx': 50002,
'electrs': 50002,
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'lnd': 8081,
'archy-lnd-ui': 8081,
'mempool': 4080,
'mempool-web': 4080,
'archy-mempool-web': 4080,
'homeassistant': 8123,
'grafana': 3000,
'searxng': 8888,
'ollama': 11434,
'onlyoffice': 9980,
'penpot': 9001,
'nextcloud': 8085,
'vaultwarden': 8082,
'jellyfin': 8096,
'photoprism': 2342,
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 81,
'portainer': 9000,
'uptime-kuma': 3001,
'tailscale': 8240,
'fedimint': 8175,
'fedimint-gateway': 8176,
'nostr-rs-relay': 18081,
'indeedhub': 7777,
'dwn': 3100,
'endurain': 8080,
}
/** Apps that need nginx proxy for iframe embedding.
* IndeedHub loads via direct port 7777 deploy script removes X-Frame-Options
* from the container's internal nginx so iframe works on all servers. */
const PROXY_APPS: Record<string, string> = {}
/** Nginx proxy paths used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'electrs': '/app/electrs/',
'btcpay-server': '/app/btcpay/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'electrumx': '/app/electrs/',
'electrs': '/app/electrs/',
'mempool-electrs': '/app/electrs/',
'mempool': '/app/mempool/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'jellyfin': '/app/jellyfin/',
'searxng': '/app/searxng/',
'filebrowser': '/app/filebrowser/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'penpot': '/app/penpot/',
'nextcloud': '/app/nextcloud/',
'vaultwarden': '/app/vaultwarden/',
'jellyfin': '/app/jellyfin/',
'photoprism': '/app/photoprism/',
'immich': '/app/immich/',
'filebrowser': '/app/filebrowser/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'immich_server': '/app/immich/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'endurain': '/app/endurain/',
'indeedhub': '/app/indeedhub/',
'dwn': '/app/dwn/',
'endurain': '/app/endurain/',
}
/** External HTTPS apps — always loaded directly */
const EXTERNAL_URLS: Record<string, string> = {
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@ -286,15 +360,47 @@ const APP_TITLES: Record<string, string> = {
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
const NEW_TAB_APPS = new Set([
'btcpay-server', // X-Frame-Options: DENY
'grafana', // X-Frame-Options: deny
'photoprism', // X-Frame-Options: DENY
'homeassistant', // X-Frame-Options: SAMEORIGIN
'vaultwarden', // X-Frame-Options: SAMEORIGIN
'nextcloud', // X-Frame-Options: SAMEORIGIN
'uptime-kuma', // X-Frame-Options: SAMEORIGIN
'penpot', // Not reachable / blocks iframe
])
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
const url = APP_URLS[appId.value]
if (!url) return ''
// Proxy paths same origin
if (url.startsWith('/')) return `${window.location.origin}${url}`
// External HTTPS sites direct
if (url.startsWith('https://')) return url
// Fallback: localhost port URLs (shouldn't reach here normally)
return url.replace('localhost', window.location.hostname)
const id = appId.value
// External HTTPS apps iframe overlay
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
const proxyPath = PROXY_APPS[id]
if (proxyPath) return `${window.location.origin}${proxyPath}`
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
if (window.location.protocol === 'https:') {
const httpsProxy = HTTPS_PROXY_PATHS[id]
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
}
// HTTP: direct port access (faster, no proxy overhead)
const port = APP_PORTS[id]
if (!port) return ''
let base = `http://${window.location.hostname}:${port}`
// Append sub-path from query param (e.g. ?path=/tx/abc123)
const subpath = route.query.path as string | undefined
if (subpath) base += subpath
return base
})
// --- Identity ---
@ -325,6 +431,9 @@ function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
// NIP-98 auto-login disabled apps like IndeedHub have their own login flow
// that properly sets up internal account state. We provide window.nostr via
// nostr-provider.js so the app's built-in "Sign In" button works.
}
async function sendIdentity(identity: SelectedIdentity) {
@ -339,6 +448,8 @@ async function sendIdentity(identity: SelectedIdentity) {
} catch {}
}
// NIP-98 auto-login removed apps handle their own login via window.nostr (NIP-07)
// --- Lifecycle ---
function onLoad() {
@ -393,7 +504,7 @@ function startLoadTimeout() {
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
goBack()
closeSession()
}
function openNewTab() {
@ -408,14 +519,14 @@ function iframeGoForward() {
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
}
function goBack() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.back()
function handleBackdropClick() {
closeSession()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.push({ name: 'apps' })
if (isInlinePanel.value) emit('close')
else router.push({ name: 'apps' })
}
function onKeyDown(e: KeyboardEvent) {
@ -463,12 +574,18 @@ async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const identityId = getStoredIdentity()?.id || null
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
try {
let result: unknown
if (method === 'getPublicKey') {
if (identityId) {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
} else {
@ -476,11 +593,13 @@ async function handleNostrRequest(event: MessageEvent) {
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
@ -489,23 +608,30 @@ async function handleNostrRequest(event: MessageEvent) {
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}
onMounted(() => {
// Apps that block iframes (X-Frame-Options) open in new tab, close session
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
return
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('click', onClickOutside)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Known blocked apps show fallback immediately
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
// Enter fullscreen if that's the stored mode
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
@ -528,19 +654,18 @@ onBeforeUnmount(() => {
width: 100%;
height: 100%;
}
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
.app-session-backdrop-panel {
/* Inline panel mode — fills content area, no blur, original layout */
.app-session-backdrop-inline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
padding: 0;
}
.app-session-inpanel {
.app-session-inline {
display: flex;
flex-direction: column;
overflow: hidden;
@ -550,10 +675,10 @@ onBeforeUnmount(() => {
}
@media (min-width: 768px) {
.app-session-backdrop-panel {
.app-session-backdrop-inline {
padding: 1.5rem;
}
.app-session-inpanel {
.app-session-inline {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);

View File

@ -81,8 +81,9 @@
:key="item.path"
:to="item.path"
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace')) }"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
@click="appLauncher.closePanel()"
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -292,6 +293,13 @@
</RouterView>
</div>
</div>
<!-- Panel mode app session renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
</div>
</Transition>
</main>
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
@ -308,11 +316,12 @@
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
@click="appLauncher.closePanel()"
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
@ -385,6 +394,8 @@ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue'
const { t } = useI18n()
import { useLoginTransitionStore } from '../stores/loginTransition'
@ -405,6 +416,7 @@ const chatFullscreen = computed(() => route.path === '/dashboard/chat')
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const loginTransition = useLoginTransitionStore()
const web5Badge = useWeb5BadgeStore()
@ -1257,6 +1269,27 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
}
/* Wrapper to contain perspective without clipping */
/* Panel mode app session — fills content area, sidebar stays untouched */
.app-panel-container {
position: absolute;
inset: 0;
z-index: 100;
}
.panel-slide-enter-active {
transition: opacity 0.25s ease;
}
.panel-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.panel-slide-enter-from {
opacity: 0;
}
.panel-slide-leave-to {
transform: translateX(40px) scale(0.97);
opacity: 0;
}
.perspective-container-wrapper {
position: relative;
overflow: hidden;

View File

@ -96,14 +96,28 @@
</div>
<div class="home-card-stats grid grid-cols-1 sm: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">{{ t('home.installed') }}</p>
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
<button
v-for="app in quickLaunchApps"
:key="app.id"
@click="useAppLauncherStore().openSession(app.id)"
class="group"
:title="app.name"
>
<div
class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center"
:style="app.bg ? { background: app.bg } : {}"
:class="{ 'bg-white/5': !app.bg }"
>
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
</div>
</button>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('home.browseStore') }}
@ -476,6 +490,12 @@ const runningCount = computed(() =>
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
)
const quickLaunchApps = [
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
]
// Network card computed values
const servicesAllRunning = computed(() =>
appCount.value > 0 && runningCount.value === appCount.value

View File

@ -203,7 +203,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
@ -214,6 +214,10 @@ import { rpcClient } from '../api/rpc-client'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
const router = useRouter()
const currentRoute = useRoute()
/** After login, redirect to the intended page or default to home */
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
@ -377,8 +381,8 @@ async function handleSetup() {
loginTransition.setJustLoggedIn(true)
await store.login(password.value)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
whooshAway.value = false
@ -421,8 +425,8 @@ async function handleLogin() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
whooshAway.value = false
@ -456,8 +460,8 @@ async function handleTotpVerify() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value
})
} catch (err) {
const msg = err instanceof Error ? err.message : ''

View File

@ -445,12 +445,12 @@ const features = computed(() => {
/** App dependency definitions */
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrs': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'mempool': [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' },
{ id: 'electrs', title: 'Electrs', dockerImage: 'docker.io/mempool/electrs:latest' },
{ id: 'electrumx', title: 'ElectrumX', dockerImage: 'docker.io/lukechilds/electrumx:v1.18.0' },
],
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
}
@ -533,7 +533,7 @@ async function installDependencies() {
installError.value = null
try {
// Install dependencies sequentially (order matters: bitcoin before electrs)
// Install dependencies sequentially (order matters: bitcoin before electrumx)
for (const dep of missingDeps) {
await rpcClient.call({
method: 'package.install',

View File

@ -42,8 +42,7 @@
</svg>
</div>
</div>
<p v-if="autoAdvancing" class="text-lg text-white/80 mb-2">DID retrieved, continuing...</p>
<p v-else class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
Your node's decentralized identifier
</p>
</div>
@ -128,10 +127,8 @@ const generatedDid = ref<string>('')
const nostrNpub = ref<string>('')
const isGenerating = ref(false)
const waitingForServer = ref(false)
const autoAdvancing = ref(false)
const didCopied = ref(false)
const npubCopied = ref(false)
const elapsedSeconds = ref(0)
const elapsedDisplay = ref('0:00')
let retryTimer: ReturnType<typeof setTimeout> | null = null
let elapsedTimer: ReturnType<typeof setInterval> | null = null
@ -141,7 +138,6 @@ function startElapsedTimer() {
startTime = Date.now()
elapsedTimer = setInterval(() => {
const secs = Math.floor((Date.now() - startTime) / 1000)
elapsedSeconds.value = secs
const m = Math.floor(secs / 60)
const s = secs % 60
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
@ -179,7 +175,6 @@ async function fetchDid() {
}
}).catch(() => { /* Nostr key may not exist yet */ })
autoAdvanceAfterDelay()
} catch {
isGenerating.value = false
if (!waitingForServer.value) {
@ -190,13 +185,6 @@ async function fetchDid() {
}
}
function autoAdvanceAfterDelay() {
autoAdvancing.value = true
setTimeout(() => {
router.push('/onboarding/identity').catch(() => {})
}, 2000)
}
onMounted(() => {
const cached = localStorage.getItem('neode_did')
const cachedNpub = localStorage.getItem('neode_nostr_npub')

View File

@ -119,7 +119,7 @@ async function signChallenge() {
if (did) {
const result = await rpcClient.call({
method: 'identity.verify',
params: { did, data: currentChallenge.value, signature: sig },
params: { did, message: currentChallenge.value, signature: sig },
}) as { valid: boolean }
verified.value = result.valid !== false
} else {

View File

@ -328,12 +328,70 @@
<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" />
</svg>
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
</div>
<!-- Incoming Transactions Badge -->
<button
v-if="incomingTxCount > 0"
@click="showIncomingTxPanel = !showIncomingTxPanel"
class="incoming-tx-badge shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
</button>
</div>
<!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="incoming-tx-row"
@click="openInMempool(tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
</div>
</div>
</transition>
<div class="space-y-3 flex-1 min-h-0">
<!-- On-chain Balance -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@ -1149,20 +1207,24 @@
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Purpose Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<!-- Avatar (clickable to edit profile) -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous',
}">
<span class="text-sm font-bold" :class="{
'text-blue-400': identity.purpose === 'personal',
'text-orange-400': identity.purpose === 'business',
'text-purple-400': identity.purpose === 'anonymous',
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</div>
</button>
<!-- Info -->
<div class="flex-1 min-w-0">
@ -1175,14 +1237,25 @@
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
}">{{ identity.purpose }}</span>
</div>
<p class="text-white/50 text-xs font-mono truncate mt-0.5" :title="identity.did">{{ identity.did }}</p>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
<svg class="w-3 h-3" 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>
</button>
</div>
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
<svg class="w-3 h-3" 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>
</button>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
<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>
</button>
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
@ -1201,6 +1274,7 @@
</div>
<!-- Create Identity Modal -->
<Teleport to="body">
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
@ -1233,8 +1307,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
@ -1247,7 +1323,221 @@
</div>
</div>
</div>
</Teleport>
<!-- Key Viewer Modal -->
<Teleport to="body">
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
}">
<svg class="w-5 h-5" :class="{
'text-blue-400': keyViewerIdentity.purpose === 'personal',
'text-orange-400': keyViewerIdentity.purpose === 'business',
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
}" 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>
</div>
<div>
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
</div>
</div>
<!-- Public Keys -->
<div class="space-y-3 mb-5">
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Public Keys
</h3>
<div class="space-y-2">
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">DID (Ed25519)</span>
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
</div>
<div class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
</div>
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
</div>
</div>
</div>
<!-- Private Keys Section -->
<div class="border-t border-white/10 pt-5">
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
Private Keys
</h3>
<!-- Locked state password required -->
<div v-if="!keyViewerPrivateKeys">
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
<div class="flex gap-2">
<input
v-model="keyViewerPassword"
type="password"
placeholder="Password"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
@keydown.enter="unlockPrivateKeys"
/>
<button
@click="unlockPrivateKeys"
:disabled="!keyViewerPassword || keyViewerUnlocking"
class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50"
>
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
</button>
</div>
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
</div>
<!-- Unlocked state show private keys -->
<div v-else class="space-y-2">
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
</div>
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
</div>
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">
Lock private keys
</button>
</div>
</div>
<!-- Close button -->
<div class="flex justify-end mt-5">
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Profile Editor Modal -->
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
<div>
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} &middot; {{ profileEditorIdentity.purpose }}</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Display Name</label>
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
</div>
</div>
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ profileError }}</p>
</div>
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
</div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ profileSaving ? 'Saving...' : 'Save' }}
</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Unified Send Modal -->
<Teleport to="body">
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
@ -1346,8 +1636,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Unified Receive Modal -->
<Teleport to="body">
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
@ -1411,6 +1703,7 @@
</div>
</div>
</div>
</Teleport>
<!-- Decentralized Web Node (DWN) -->
<div class="glass-card p-6 mb-8">
@ -1641,6 +1934,7 @@
</div>
<!-- Domains Management Modal -->
<Teleport to="body">
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
@ -1716,8 +2010,10 @@
</div>
</div>
</div>
</Teleport>
<!-- Relay Management Modal -->
<Teleport to="body">
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
@ -1759,6 +2055,7 @@
</div>
</div>
</div>
</Teleport>
<!-- Identity Toast -->
<Transition name="content-fade">
@ -1770,7 +2067,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
@ -2194,6 +2491,62 @@ const connectingWallet = ref(false)
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
// Incoming Transactions
interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
const walletTransactions = ref<WalletTransaction[]>([])
const showIncomingTxPanel = ref(false)
const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming')
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
async function loadTransactions() {
try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || []
// Auto-show panel when new unconfirmed incoming txs appear
const pending = res.incoming_pending_count || 0
if (pending > 0 && !showIncomingTxPanel.value) {
showIncomingTxPanel.value = true
}
} catch {
walletTransactions.value = []
}
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
}
// Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
// Nostr Relays
interface NostrRelayData {
url: string
@ -2988,6 +3341,16 @@ function copyOnionAddress() {
}
// --- Identity Management ---
interface IdentityProfile {
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string
}
interface ManagedIdentity {
id: string
name: string
@ -2996,6 +3359,131 @@ interface ManagedIdentity {
did: string
created_at: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
profile?: IdentityProfile
}
// --- Key Viewer Modal ---
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
const keyViewerPassword = ref('')
const keyViewerUnlocking = ref(false)
const keyViewerError = ref('')
const keyViewerCopied = ref<string | null>(null)
function openKeyViewer(identity: ManagedIdentity) {
keyViewerIdentity.value = identity
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
}
function closeKeyViewer() {
// Clear sensitive data immediately
keyViewerPrivateKeys.value = null
keyViewerPassword.value = ''
keyViewerError.value = ''
keyViewerIdentity.value = null
}
async function unlockPrivateKeys() {
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
keyViewerUnlocking.value = true
keyViewerError.value = ''
try {
const res = await rpcClient.call<{
ed25519_secret_hex: string
nostr_secret_hex: string | null
nostr_nsec: string | null
}>({
method: 'identity.export-keys',
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
})
keyViewerPrivateKeys.value = {
ed25519_secret_hex: res.ed25519_secret_hex,
nostr_secret_hex: res.nostr_secret_hex || '',
nostr_nsec: res.nostr_nsec || '',
}
keyViewerPassword.value = '' // Clear password from memory immediately
} catch (err: unknown) {
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
} finally {
keyViewerUnlocking.value = false
}
}
function copyKeyValue(label: string, value: string) {
safeClipboardWrite(value)
keyViewerCopied.value = label
setTimeout(() => { keyViewerCopied.value = null }, 2000)
}
// --- Profile Editor ---
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profileSaving = ref(false)
const profilePublishing = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
}
async function saveProfile() {
if (!profileEditorIdentity.value || profileSaving.value) return
profileSaving.value = true
profileError.value = ''
profileSuccess.value = ''
try {
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
await loadIdentities()
profileSuccess.value = 'Profile saved'
setTimeout(() => { profileSuccess.value = '' }, 3000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to save'
} finally {
profileSaving.value = false
}
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
try {
// Save first, then publish
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{ event_id: string }>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
})
await loadIdentities()
profileSuccess.value = `Published to relay (${res.event_id.slice(0, 12)}...)`
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
} finally {
profilePublishing.value = false
}
}
const managedIdentities = ref<ManagedIdentity[]>([])
@ -3090,7 +3578,14 @@ onMounted(() => {
loadNostrRelays()
loadCredentials()
loadLndBalances()
loadTransactions()
detectHardwareWallets()
// Auto-refresh wallet balances and transactions every 30s
walletRefreshInterval = setInterval(() => {
loadLndBalances()
loadTransactions()
loadEcashBalance()
}, 30000)
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
if (route.query.tab === 'messages') {
nodesContainerTab.value = 'messages'
@ -3101,6 +3596,13 @@ onMounted(() => {
}
})
onUnmounted(() => {
if (walletRefreshInterval) {
clearInterval(walletRefreshInterval)
walletRefreshInterval = null
}
})
watch(() => route.query.tab, (tab) => {
if (tab === 'messages') {
nodesContainerTab.value = 'messages'

View File

@ -607,10 +607,10 @@ PYEOF
' 2>&1 | sed 's/^/ /' || true
fi
# Rebuild and recreate Electrs UI container (port 50002)
echo "$(timestamp) Rebuilding Electrs UI..."
# Rebuild and recreate ElectrumX UI container (port 50002)
echo "$(timestamp) Rebuilding ElectrumX UI..."
if 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)..."
echo " Recreating ElectrumX UI container (port 50002, host network)..."
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
@ -621,7 +621,22 @@ PYEOF
' 2>&1 | sed 's/^/ /' || true
fi
# Bitcoin Knots: required for Mempool, Electrs, BTCPay, Fedimint
# Rebuild and recreate Bitcoin UI container (host network, port 8334 in nginx.conf)
# Host network required: bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332
echo "$(timestamp) Rebuilding Bitcoin UI..."
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/bitcoin-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t bitcoin-ui:latest . || sudo docker build --no-cache -t bitcoin-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating Bitcoin UI container (port 8334, host network)..."
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 bitcoin-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-bitcoin-ui --network host --restart unless-stopped bitcoin-ui:latest
' 2>&1 | sed 's/^/ /' || true
fi
# Bitcoin Knots: required for Mempool, ElectrumX, BTCPay, Fedimint
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
echo "$(timestamp) Ensuring Bitcoin Knots..."
ssh $SSH_OPTS "$TARGET_HOST" "
@ -632,7 +647,7 @@ PYEOF
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
# Demo mode: prune=550 saves ~194GB disk, but disables txindex (incompatible with electrs)
# Demo mode: prune=550 saves ~194GB disk, but disables txindex (incompatible with electrumx)
if [ "$DEMO" = "true" ]; then
BTC_EXTRA_ARGS="-prune=550"
BTC_DBCACHE=512
@ -664,7 +679,7 @@ PYEOF
TARGET_IP='$TARGET_IP'
NET_OPT='--network archy-net'
# Clean any duplicate/old mempool containers (user may have two versions)
# EXCLUDE mempool-electrs - indexing takes days, do not recreate on every deploy
# EXCLUDE electrumx/mempool-electrs - indexing takes days, do not recreate on every deploy
for c in mempool mempool-api mempool-web archy-mempool-api archy-mempool-web; do
sudo \$DOCKER stop \$c 2>/dev/null
sudo \$DOCKER rm -f \$c 2>/dev/null
@ -686,34 +701,28 @@ PYEOF
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 ONLY if missing - do NOT recreate (indexing takes days, data is 800GB+)
# One-time migration: if on bridge (wrong network), recreate with archy-net so it can reach bitcoin-knots
# Stop and remove old mempool-electrs if present (replaced by electrumx)
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
MEMPOOL_ELECTRS_NET=\$(sudo \$DOCKER inspect mempool-electrs --format '{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}' 2>/dev/null || true)
if [ \"\$MEMPOOL_ELECTRS_NET\" = \"bridge\" ] || [ \"\$MEMPOOL_ELECTRS_NET\" = \"\" ]; then
echo ' Migrating mempool-electrs to archy-net (preserving 800GB+ index)...'
sudo \$DOCKER stop mempool-electrs 2>/dev/null
sudo \$DOCKER rm mempool-electrs 2>/dev/null
fi
echo ' Removing old mempool-electrs (replaced by ElectrumX)...'
sudo \$DOCKER stop mempool-electrs 2>/dev/null
sudo \$DOCKER rm -f mempool-electrs 2>/dev/null
fi
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
echo ' Starting existing mempool-electrs (preserving index)...'
sudo \$DOCKER start mempool-electrs 2>/dev/null || true
# Create electrumx ONLY if missing - do NOT recreate (indexing takes days)
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
echo ' Starting existing electrumx (preserving index)...'
sudo \$DOCKER start electrumx 2>/dev/null || true
else
echo ' Creating mempool-electrs (indexer - may take days to sync, do not recreate)...'
sudo mkdir -p /var/lib/archipelago/mempool-electrs
# Use archy-net + bitcoin-knots for reliable Bitcoin connectivity (not host IP from bridge)
sudo \$DOCKER run -d --name mempool-electrs --restart unless-stopped \$NET_OPT \
echo ' Creating electrumx (indexer - may take days to sync, do not recreate)...'
sudo mkdir -p /var/lib/archipelago/electrumx
sudo \$DOCKER run -d --name electrumx --restart unless-stopped \$NET_OPT \
-p 50001:50001 \
-v /var/lib/archipelago/mempool-electrs:/data \
docker.io/mempool/electrs:latest \
--daemon-rpc-addr bitcoin-knots:8332 \
--cookie archipelago:archipelago123 \
--jsonrpc-import \
--electrum-rpc-addr 0.0.0.0:50001 \
--db-dir /data \
--lightmode
-v /var/lib/archipelago/electrumx:/data \
-e DAEMON_URL=http://archipelago:archipelago123@bitcoin-knots:8332/ \
-e COIN=Bitcoin \
-e DB_DIRECTORY=/data \
-e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \
docker.io/lukechilds/electrumx:v1.18.0
fi
fi
# Create/recreate mempool-api (backend on 8999) - required for mempool to work
@ -729,7 +738,7 @@ PYEOF
-p 8999:8999 \
-v /var/lib/archipelago/mempool:/data \
-e MEMPOOL_BACKEND=electrum \
-e ELECTRUM_HOST=mempool-electrs \
-e ELECTRUM_HOST=electrumx \
-e ELECTRUM_PORT=50001 \
-e ELECTRUM_TLS_ENABLED=false \
-e CORE_RPC_HOST=\$TARGET_IP \
@ -896,7 +905,7 @@ import json
services = [
{"name": "archipelago", "local_port": 80, "enabled": True},
{"name": "bitcoin", "local_port": 8333, "enabled": True},
{"name": "electrs", "local_port": 50001, "enabled": True},
{"name": "electrumx", "local_port": 50001, "enabled": True},
{"name": "lnd", "local_port": 9735, "enabled": True},
{"name": "btcpay", "local_port": 23000, "enabled": True},
{"name": "mempool", "local_port": 4080, "enabled": True},
@ -923,7 +932,7 @@ try:
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
lines.append("")
except Exception:
for n, p in [("archipelago",80),("bitcoin",8333),("electrs",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
for n, p in [("archipelago",80),("bitcoin",8333),("electrumx",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
lines.append("")
@ -1231,6 +1240,44 @@ LNDCONF
fi # end FRONTEND_ONLY guard
# Ensure UFW allows forwarded traffic (required for podman container port access from LAN)
ssh $SSH_OPTS "$TARGET_HOST" '
if grep -q "DEFAULT_FORWARD_POLICY=\"DROP\"" /etc/default/ufw 2>/dev/null; then
sudo sed -i "s/DEFAULT_FORWARD_POLICY=\"DROP\"/DEFAULT_FORWARD_POLICY=\"ACCEPT\"/" /etc/default/ufw
sudo ufw reload 2>/dev/null
echo " Fixed UFW forward policy (was DROP, now ACCEPT)"
fi
' 2>&1 | sed 's/^/ /' || true
# Fix IndeedHub for iframe + NIP-07: remove X-Frame-Options, inject nostr-provider.js
ssh $SSH_OPTS "$TARGET_HOST" '
if sudo podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then
CHANGED=false
# Remove X-Frame-Options so iframe works
if sudo podman exec indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then
sudo podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf
CHANGED=true
echo " Removed X-Frame-Options from IndeedHub"
fi
# Inject nostr-provider.js for NIP-07 signing
if ! sudo podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then
sudo podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null
echo " Copied nostr-provider.js into IndeedHub"
fi
if ! sudo podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "/try_files.*index.html/i\\ sub_filter_once on;\n sub_filter '"'"'</head>'"'"' '"'"'<script src=\"/nostr-provider.js\"></script></head>'"'"';" /tmp/ih-nginx.conf
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null
rm -f /tmp/ih-nginx.conf
CHANGED=true
echo " Injected nostr-provider.js into IndeedHub nginx"
fi
if [ "$CHANGED" = true ]; then
sudo podman exec indeedhub nginx -s reload 2>/dev/null
fi
fi
' 2>&1 | sed 's/^/ /' || true
# Post-deploy health check — wait up to 60s for server to come healthy
echo ""
echo "$(timestamp) 🩺 Post-deploy health check..."

View File

@ -66,19 +66,30 @@ $DOCKER network create archy-net 2>/dev/null || true
log "=== Tier 1: Databases & Core Infrastructure ==="
# 1. Bitcoin Knots (matches deploy exactly)
# Auto-detect: if disk < 1TB, use pruning to prevent disk-full crashes
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then
log "Creating Bitcoin Knots..."
mkdir -p /var/lib/archipelago/bitcoin
DISK_GB=$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9')
if [ "${DISK_GB:-0}" -lt 1000 ]; then
BTC_EXTRA_ARGS="-prune=550"
BTC_DBCACHE=512
log " Small disk (${DISK_GB}GB) — enabling pruning"
else
BTC_EXTRA_ARGS="-txindex=1"
BTC_DBCACHE=4096
log " Large disk (${DISK_GB}GB) — enabling txindex"
fi
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8332:8332 -p 8333:8333 \
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
docker.io/bitcoinknots/bitcoin:latest \
-server=1 -txindex=1 \
-server=1 $BTC_EXTRA_ARGS \
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
-rpcuser=archipelago -rpcpassword=archipelago123 \
-dbcache=4096 2>>"$LOG"; then
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then
log "Bitcoin Knots started"
else
log "Bitcoin Knots failed (may already exist)"
@ -105,17 +116,18 @@ MYSQL_CNT=$($DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mem
MYSQL_CNT=${MYSQL_CNT:-archy-mempool-db}
$DOCKER network connect archy-net "$MYSQL_CNT" 2>/dev/null || true
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
$DOCKER start mempool-electrs 2>/dev/null || true
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
$DOCKER start electrumx 2>/dev/null || true
else
log "Creating mempool-electrs..."
mkdir -p /var/lib/archipelago/mempool-electrs
$DOCKER run -d --name mempool-electrs --restart unless-stopped --network archy-net \
-p 50001:50001 -v /var/lib/archipelago/mempool-electrs:/data \
docker.io/mempool/electrs:latest \
--daemon-rpc-addr bitcoin-knots:8332 --cookie archipelago:archipelago123 \
--jsonrpc-import --electrum-rpc-addr 0.0.0.0:50001 --db-dir /data --lightmode 2>>"$LOG" || true
log "Creating electrumx..."
mkdir -p /var/lib/archipelago/electrumx
$DOCKER run -d --name electrumx --restart unless-stopped --network archy-net \
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
-e DAEMON_URL=http://archipelago:archipelago123@bitcoin-knots:8332/ \
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
-e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \
docker.io/lukechilds/electrumx:v1.18.0 2>>"$LOG" || true
fi
fi
@ -124,7 +136,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
mkdir -p /var/lib/archipelago/mempool
$DOCKER run -d --name mempool-api --restart unless-stopped --network archy-net \
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=mempool-electrs -e ELECTRUM_PORT=50001 \
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -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 \
@ -139,21 +151,21 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
fi
# 2b. Electrs UI (status dashboard on port 50002, host network for backend access)
# 2b. ElectrumX UI (status dashboard on port 50002, host network for backend access)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrs-ui; then
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'electrs-ui'; then
log "Starting Electrs UI from pre-built image..."
log "Starting ElectrumX UI from pre-built image..."
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
localhost/electrs-ui:latest 2>>"$LOG" || \
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
electrs-ui:latest 2>>"$LOG" || true
elif [ -d /opt/archipelago/docker/electrs-ui ]; then
log "Building and starting Electrs UI from source..."
log "Building and starting ElectrumX UI from source..."
$DOCKER build -t electrs-ui:latest /opt/archipelago/docker/electrs-ui 2>>"$LOG" && \
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
electrs-ui:latest 2>>"$LOG" || true
else
log "Electrs UI: no image or source found, skipping"
log "ElectrumX UI: no image or source found, skipping"
fi
fi
@ -571,9 +583,19 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
$DOCKER run -d --name indeedhub --restart unless-stopped \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
-p 8190:3000 \
-p 7777:7777 \
-e NODE_ENV=production -e NEXT_TELEMETRY_DISABLED=1 \
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
# Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel
sleep 2
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
$DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
$DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
fi
$DOCKER exec indeedhub nginx -s reload 2>/dev/null || true
log "Applied IndeedHub iframe fix (removed X-Frame-Options)"
fi
fi
fi
@ -584,7 +606,7 @@ for ui in bitcoin-ui lnd-ui; do
continue
fi
case $ui in
bitcoin-ui) PORT_ARG="-p 8334:80"; NET_ARG="" ;;
bitcoin-ui) PORT_ARG=""; NET_ARG="--network host" ;; # host network: proxies Bitcoin RPC at 127.0.0.1:8332
lnd-ui) PORT_ARG="-p 8081:80"; NET_ARG="" ;;
esac
CONTAINER_NAME="archy-$ui"
@ -593,10 +615,17 @@ for ui in bitcoin-ui lnd-ui; do
IMG=$($DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep "$ui" | head -1)
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$IMG" 2>>"$LOG" || true
elif [ -d "/opt/archipelago/docker/$ui" ]; then
log "Building $ui from source..."
log "Building $ui from source (/opt/archipelago/docker/$ui)..."
if $DOCKER build -t "$ui:latest" "/opt/archipelago/docker/$ui" 2>>"$LOG"; then
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$ui:latest" 2>>"$LOG" || true
fi
elif [ -d "/home/archipelago/archy/docker/$ui" ]; then
log "Building $ui from source (/home/archipelago/archy/docker/$ui)..."
if $DOCKER build -t "$ui:latest" "/home/archipelago/archy/docker/$ui" 2>>"$LOG"; then
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$ui:latest" 2>>"$LOG" || true
fi
else
log "$ui: no image or source found, skipping"
fi
done