diff --git a/.claude/skills/podman-doctor/SKILL.md b/.claude/skills/podman-doctor/SKILL.md new file mode 100644 index 00000000..44d13bc0 --- /dev/null +++ b/.claude/skills/podman-doctor/SKILL.md @@ -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" != "" ]; 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" = "" ] || [ -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. diff --git a/.claude/skills/podman-doctor/references/common-failures.md b/.claude/skills/podman-doctor/references/common-failures.md new file mode 100644 index 00000000..75f2afb0 --- /dev/null +++ b/.claude/skills/podman-doctor/references/common-failures.md @@ -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. diff --git a/.claude/skills/podman-doctor/references/port-map.md b/.claude/skills/podman-doctor/references/port-map.md new file mode 100644 index 00000000..ad960883 --- /dev/null +++ b/.claude/skills/podman-doctor/references/port-map.md @@ -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 diff --git a/.claude/skills/podman-fix/SKILL.md b/.claude/skills/podman-fix/SKILL.md new file mode 100644 index 00000000..e3b3e413 --- /dev/null +++ b/.claude/skills/podman-fix/SKILL.md @@ -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. diff --git a/.claude/skills/podman-uptime/SKILL.md b/.claude/skills/podman-uptime/SKILL.md new file mode 100644 index 00000000..5b080e56 --- /dev/null +++ b/.claude/skills/podman-uptime/SKILL.md @@ -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 diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 6f2cefb2..5e3def58 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index aee1539c..005c7095 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -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 = 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::>() - ).unwrap_or_default(), + "ports": ports, "lan_address": lan_address, }) }) diff --git a/core/archipelago/src/api/rpc/identity.rs b/core/archipelago/src/api/rpc/identity.rs index c3b83f46..e1a86fc9 100644 --- a/core/archipelago/src/api/rpc/identity.rs +++ b/core/archipelago/src/api/rpc/identity.rs @@ -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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs index f218838b..0b054b6d 100644 --- a/core/archipelago/src/api/rpc/lnd.rs +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -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 { + 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 = 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 = 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 diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index a77adff7..2cd019d4 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -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 { +/// Extract a named cookie value from headers. +fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option { + 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 { None } +/// Extract the csrf_token cookie value from headers. +fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option { + 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 diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index 120d9f7c..fbd7426d 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -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> { let patterns: Vec = 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 { "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 { "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" => ( diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index b66a8929..263e8236 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -592,8 +592,8 @@ async fn read_temperatures() -> Result> { } 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, @@ -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(); diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 12194c72..ad6bbfbe 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -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 { diff --git a/core/archipelago/src/electrs_status.rs b/core/archipelago/src/electrs_status.rs index b3c957e9..9cf53cf5 100644 --- a/core/archipelago/src/electrs_status.rs +++ b/core/archipelago/src/electrs_status.rs @@ -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 { - 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 { + 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 { 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 { .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 { 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 { diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 824db797..8379e82e 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -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" diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 6b8c5cd7..f7a2aab9 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -48,6 +48,28 @@ pub struct IdentityRecord { pub nostr_pubkey: Option, /// Nostr public key in bech32 npub format (NIP-19) pub nostr_npub: Option, + /// Nostr profile metadata (NIP-01 kind 0) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nip05: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lud16: Option, } /// On-disk format for identity storage (includes secret key bytes). @@ -64,6 +86,9 @@ struct IdentityFile { nostr_secret_hex: Option, #[serde(default)] nostr_pubkey_hex: Option, + /// Nostr profile metadata + #[serde(default)] + profile: Option, } 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 { + 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 { + 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, }) } diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 8fa438d4..3ee1b712 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -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; + 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>>, + 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 = 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 = 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 = 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 { + 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] { diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index ca6ef83b..12bed8f3 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -66,6 +66,41 @@ impl PodmanClient { } } + /// Map container name to its UI launch URL + fn lan_address_for(name: &str) -> Option { + 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::(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, }); } } diff --git a/docker/bitcoin-ui/Dockerfile b/docker/bitcoin-ui/Dockerfile index 777dd66d..a01b98cf 100644 --- a/docker/bitcoin-ui/Dockerfile +++ b/docker/bitcoin-ui/Dockerfile @@ -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 && \ diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index fc039a0c..ebdd0e13 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -7,7 +7,7 @@ Bitcoin Knots - Archipelago - + @@ -76,6 +98,7 @@
+
@@ -84,8 +107,11 @@
-

Electrs

-

Bitcoin Electrum indexer for Mempool & Electrum clients

+
+

ElectrumX

+ v1.18.0 +
+

Bitcoin Electrum server for wallet connections

@@ -97,7 +123,8 @@
-
+ +
@@ -139,17 +166,215 @@
+ + +
+

Connect Your Wallet

+

Use the following details to connect your wallet or application to ElectrumX.

+ +
+ + +
+ + +
+
+
+
+
Address
+
+ - + +
+
+
+
+
Port
+
+ 50001 + +
+
+
+
SSL
+
+ Disabled + +
+
+
+
+
+ + + + +
+ Connect using Sparrow Wallet, Electrum, or any Electrum-protocol compatible wallet. Set the server to the address and port shown above with SSL disabled. +
+
+ diff --git a/docker/electrs-ui/qrcode.js b/docker/electrs-ui/qrcode.js new file mode 100644 index 00000000..df13f829 --- /dev/null +++ b/docker/electrs-ui/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md new file mode 100644 index 00000000..774a9c49 --- /dev/null +++ b/docs/MASTER_PLAN.md @@ -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 + + diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index ab4069e8..c3caf5eb 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -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 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index e0a38581..103f0eea 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -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 '' ''; } - 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/ { diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index a0e9b874..e5fd9097 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -218,7 +218,7 @@ location /app/bitcoin-ui/ { sub_filter_once on; sub_filter '' ''; } -location /app/electrs/ { +location /app/electrumx/ { proxy_pass http://127.0.0.1:50002/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index f12010f6..c45c77fa 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.6f1usind3cc" + "revision": "0.tmc04bnmkho" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index fc503e2e..845aa190 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -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", diff --git a/neode-ui/package.json b/neode-ui/package.json index a01a6fab..0e515d33 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -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", diff --git a/neode-ui/public/assets/img/app-icons/electrumx.webp b/neode-ui/public/assets/img/app-icons/electrumx.webp new file mode 100644 index 00000000..4d05b2d2 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/electrumx.webp differ diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index ec91a598..fc124bd9 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -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 = + '
' + + '' + + '' + + '' + + '' + + '
' + (message || 'Signing in...') + '
' + + '
'; + 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); + }); })(); diff --git a/neode-ui/src/components/NostrIdentityPicker.vue b/neode-ui/src/components/NostrIdentityPicker.vue index 9b7e8d14..2f28d470 100644 --- a/neode-ui/src/components/NostrIdentityPicker.vue +++ b/neode-ui/src/components/NostrIdentityPicker.vue @@ -6,13 +6,16 @@ class="fixed inset-0 z-[3100] flex items-center justify-center p-4" @click="$emit('cancel')" > - -
+ +
-
+
@@ -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" >
{{ identity.name.charAt(0).toUpperCase() }} @@ -85,10 +91,10 @@
-
-
+
+
-
+
@@ -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 @@ -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' } } diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 944285fd..f448748e 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -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 } diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 2e045169..0a462d52 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 = {} +/** 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 = { - '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 = { + '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(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 = { 'botfights.net': 'botfights', @@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { open, openSession, close, + closePanel, + panelAppId, showConsent, consentRequest, approveConsent, diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index 617f381b..1750a742 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -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 = { + '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) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 164f43ff..2c360150 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -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. diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 983122c2..882288c0 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record = { '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 = { 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 { diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index c3e75ebc..e1a14d09 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -1,9 +1,9 @@