feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements
Bitcoin UI: - Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts) - Make all asset paths relative for nginx proxy compatibility - Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely) - Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332) HTTPS mixed content fix: - Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS, iframe loads through nginx proxy instead of direct HTTP port - Prevents browser blocking HTTP iframes inside HTTPS pages - All Tailscale servers use HTTPS, this was breaking all app iframes Deploy & first-boot improvements: - first-boot-containers.sh auto-detects disk size for pruning vs txindex - first-boot-containers.sh checks fallback source path for UI containers - Added mempool-electrs to APP_PORTS mapping - ElectrumX container creation in first-boot - Podman doctor/fix/uptime skills added Also includes: session persistence, identity management, LND transactions, ElectrumX status UI, nostr-provider improvements, Web5 enhancements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e54b8bd4d
commit
367b483a72
156
.claude/skills/podman-doctor/SKILL.md
Normal file
156
.claude/skills/podman-doctor/SKILL.md
Normal file
@ -0,0 +1,156 @@
|
||||
---
|
||||
name: podman-doctor
|
||||
description: >
|
||||
Comprehensive Podman container diagnostic for Archipelago. Audits all running containers,
|
||||
port mappings, network connectivity, health status, restart policies, and config consistency
|
||||
across all 4 layers (backend Rust, Podman runtime, Nginx proxy, frontend routing).
|
||||
Use when asked to "diagnose containers", "check podman", "why is app not working",
|
||||
"container health check", "port not reachable", "audit containers", "podman status",
|
||||
or when any container/app is misbehaving.
|
||||
allowed-tools: Bash Read Glob Grep
|
||||
---
|
||||
|
||||
# Podman Doctor — Container Infrastructure Diagnostics
|
||||
|
||||
Systematic diagnostic for Archipelago's Podman container stack. Catches port conflicts, network misconfigurations, health failures, missing restart policies, and config drift across all layers.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
If $ARGUMENTS is provided, focus diagnosis on that specific app/container. Otherwise run full audit.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Runtime State
|
||||
|
||||
Run these on the server:
|
||||
|
||||
```bash
|
||||
# All containers with status, ports, networks
|
||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Networks}}"
|
||||
|
||||
# Check for port conflicts on known ports
|
||||
sudo ss -tlnp | grep -E ":(80|443|3000|4080|5678|8080|8081|8082|8083|8085|8096|8123|8173|8174|8175|8240|8332|8333|8334|8888|9735|10009|11434|23000|50001)\b"
|
||||
```
|
||||
|
||||
### Step 2: Check Restart Policies
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is the #1 cause of downtime after reboots.
|
||||
|
||||
```bash
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
echo -n "$c: "
|
||||
sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}"
|
||||
done
|
||||
```
|
||||
|
||||
**Red flag**: `no` or empty = container won't survive reboot.
|
||||
|
||||
### Step 3: Verify Port Mapping Consistency
|
||||
|
||||
Cross-reference these 4 layers — mismatches between ANY two cause "app not loading" bugs:
|
||||
|
||||
**Layer 1 — Backend Config (Rust)**: Read `core/archipelago/src/api/rpc/package.rs`, look at `get_app_config()` port mappings.
|
||||
|
||||
**Layer 2 — Podman Runtime**: `sudo podman ps --format "{{.Names}}: {{.Ports}}"`
|
||||
|
||||
**Layer 3 — Nginx Proxy**: Read these for `/app/{id}/` location blocks:
|
||||
- `image-recipe/configs/nginx-archipelago.conf` (HTTP)
|
||||
- `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS)
|
||||
|
||||
**Layer 4 — Frontend Routing**: Read `neode-ui/src/stores/appLauncher.ts` — `PORT_TO_APP_ID` map.
|
||||
|
||||
| Symptom | Root Cause |
|
||||
|---------|-----------|
|
||||
| App iframe shows 502/504 | Nginx proxies to wrong port, or container not running |
|
||||
| App loads wrong content | Port collision — two containers on same host port |
|
||||
| Works on port but not /app/ path | Missing nginx location block |
|
||||
| Frontend can't find app | PORT_TO_APP_ID missing in appLauncher.ts |
|
||||
|
||||
### Step 4: Network Connectivity Audit
|
||||
|
||||
```bash
|
||||
# Networks and their containers
|
||||
sudo podman network ls
|
||||
sudo podman network inspect archy-net 2>/dev/null || echo "WARNING: archy-net missing!"
|
||||
```
|
||||
|
||||
**Must be on archy-net**: bitcoin-knots, lnd, electrs, mempool, btcpay-server, nbxplorer, fedimint, fedimint-gateway, nostr-rs-relay, indeedhub, ollama, open-webui
|
||||
|
||||
**Must NOT be on archy-net**: grafana, nextcloud, filebrowser, vaultwarden, bitcoin-ui, lnd-ui, tailscale (host network)
|
||||
|
||||
### Step 5: Health Check Status
|
||||
|
||||
```bash
|
||||
# Containers with health checks — are they passing?
|
||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
||||
health=$(sudo podman inspect "$c" --format "{{.State.Health.Status}}" 2>/dev/null)
|
||||
if [ -n "$health" ] && [ "$health" != "<no value>" ]; then
|
||||
echo "$c: $health"
|
||||
fi
|
||||
done
|
||||
|
||||
# Containers WITHOUT health checks (gap in monitoring)
|
||||
for c in $(sudo podman ps --format "{{.Names}}"); do
|
||||
hc=$(sudo podman inspect "$c" --format "{{.Config.Healthcheck}}" 2>/dev/null)
|
||||
if [ "$hc" = "<nil>" ] || [ -z "$hc" ]; then
|
||||
echo "NO HEALTHCHECK: $c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Step 6: Resource & Failure Analysis
|
||||
|
||||
```bash
|
||||
# Resource usage
|
||||
sudo podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
|
||||
|
||||
# Recent deaths (last 24h)
|
||||
sudo podman events --filter event=died --since 24h 2>/dev/null | tail -20
|
||||
|
||||
# OOM kills
|
||||
sudo podman ps -a --format "{{.Names}}" | while read c; do
|
||||
oom=$(sudo podman inspect "$c" --format "{{.State.OOMKilled}}" 2>/dev/null)
|
||||
[ "$oom" = "true" ] && echo "OOM KILLED: $c"
|
||||
done
|
||||
|
||||
# Non-zero exits
|
||||
sudo podman ps -a --filter status=exited --format "{{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
### Step 7: Systemd Integration
|
||||
|
||||
```bash
|
||||
systemctl is-active archipelago nginx
|
||||
systemctl list-units --type=service | grep -i podman
|
||||
systemctl list-timers --all | grep -i -E "podman|container|archipelago"
|
||||
```
|
||||
|
||||
### Step 8: Generate Report
|
||||
|
||||
Produce a structured report:
|
||||
|
||||
```
|
||||
## Container Diagnostic Report
|
||||
|
||||
### Summary
|
||||
- Total containers: X running, Y stopped, Z unhealthy
|
||||
- Port conflicts: [list or "none"]
|
||||
- Missing restart policies: [list or "none"]
|
||||
- Network issues: [list or "none"]
|
||||
- Health check gaps: [list]
|
||||
|
||||
### Critical Issues (fix immediately)
|
||||
1. ...
|
||||
|
||||
### Warnings (fix soon)
|
||||
1. ...
|
||||
|
||||
### Recommended Actions
|
||||
1. ...
|
||||
```
|
||||
|
||||
After diagnosis, suggest running `/podman-fix` for any issues found.
|
||||
|
||||
## Port Reference
|
||||
|
||||
See `references/port-map.md` for the canonical port assignment table across all 4 layers.
|
||||
55
.claude/skills/podman-doctor/references/common-failures.md
Normal file
55
.claude/skills/podman-doctor/references/common-failures.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Common Podman Failure Patterns
|
||||
|
||||
## Container Won't Start
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `exec format error` | Binary built on wrong arch | Rebuild on the Linux server |
|
||||
| `address already in use` | Port conflict | `ss -tlnp \| grep :PORT` to find offender |
|
||||
| `permission denied` | Missing capability or read-only root | Check `get_app_capabilities()`, add tmpfs |
|
||||
| `OCI runtime error` | Corrupt container state | `podman rm -f NAME && recreate` |
|
||||
| `image not known` | Image not pulled | `podman pull IMAGE:TAG` |
|
||||
| `no such network` | Network missing | `podman network create archy-net` |
|
||||
|
||||
## Container Starts But App Unreachable
|
||||
|
||||
| Symptom | Check Layer | Fix |
|
||||
|---------|------------|-----|
|
||||
| Direct port works, /app/ doesn't | Nginx config | Add `/app/{id}/` location block |
|
||||
| Neither works | Podman ports | `podman port NAME` — verify mapping exists |
|
||||
| Port mapped but refused | Container logs | App crashing internally — check logs |
|
||||
| Works sometimes | Resources | Check OOM kills, CPU, disk space |
|
||||
| 502 Bad Gateway | Nginx→Container | Wrong port in proxy_pass or container restarted |
|
||||
|
||||
## Container Keeps Dying
|
||||
|
||||
| Pattern | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Exits immediately (code 1) | Config error | Check `podman logs NAME` |
|
||||
| Dies after minutes | OOM killed | Increase `--memory` limit |
|
||||
| Dies when dep restarts | No restart policy | Add `--restart unless-stopped` |
|
||||
| Crash loop | Repeated crash | Fix root cause, don't just restart |
|
||||
|
||||
## Network Issues
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Can't resolve container names | Not on archy-net | Recreate with `--network=archy-net` |
|
||||
| Can't reach internet | DNS missing | Add `--dns 1.1.1.1` |
|
||||
| Container-to-container timeout | Different networks | Put both on same network |
|
||||
|
||||
## Capability Reference
|
||||
|
||||
| Capability | Apps That Need It | Failure Mode |
|
||||
|-----------|------------------|-------------|
|
||||
| CHOWN | nextcloud, homeassistant, btcpay, jellyfin, portainer | Can't chown during setup |
|
||||
| SETUID/SETGID | nextcloud, homeassistant, btcpay, jellyfin | Can't switch to service user |
|
||||
| DAC_OVERRIDE | nextcloud, homeassistant, btcpay | Can't access cross-UID files |
|
||||
| FOWNER | bitcoin-knots, lnd, fedimint | Can't modify data dir perms |
|
||||
| NET_BIND_SERVICE | nginx-proxy-manager, vaultwarden | Can't bind ports <1024 |
|
||||
|
||||
## Read-Only Safe Apps
|
||||
|
||||
Only these 8 apps can run with `--read-only`: searxng, grafana, filebrowser, electrs, nostr-rs-relay, ollama, indeedhub
|
||||
|
||||
All others need writable root or will fail silently.
|
||||
71
.claude/skills/podman-doctor/references/port-map.md
Normal file
71
.claude/skills/podman-doctor/references/port-map.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Archipelago Canonical Port Map
|
||||
|
||||
All port assignments across the 4 configuration layers. When adding or debugging an app, every row must be consistent across all columns.
|
||||
|
||||
## Bitcoin Stack
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| bitcoin-knots | 8332, 8333 | 8332, 8333 | archy-net | /app/bitcoin-knots/ | 8332→bitcoin-knots |
|
||||
| bitcoin-ui | 8334 | 80 | bridge | /app/bitcoin-ui/ | 8334→bitcoin-knots |
|
||||
| electrs | 50001 | 50001 | archy-net | /app/electrs/ | 50001→electrs |
|
||||
| lnd | 9735, 10009, 8080 | 9735, 10009, 8080 | archy-net | /app/lnd/ | 10009→lnd |
|
||||
| lnd-ui (RTL) | 8081 | 80 | bridge | /app/lnd-ui/ | 8081→lnd |
|
||||
|
||||
## Lightning & Payment
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| btcpay-server | 23000 | 49392 | archy-net | /app/btcpay/ | 23000→btcpay-server |
|
||||
| nbxplorer | 24444 | 32838 | archy-net | N/A (internal) | N/A |
|
||||
| fedimint | 8173, 8174, 8175 | 8173, 8174, 8175 | archy-net | /app/fedimint/ | 8174→fedimint |
|
||||
| fedimint-gateway | 8175 | 8175 | archy-net | /app/fedimint-gateway/ | 8175→fedimint-gateway |
|
||||
|
||||
## Explorer & Monitoring
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| mempool | 4080 | 8080 | archy-net | /app/mempool/ | 4080→mempool |
|
||||
| grafana | 3000 | 3000 | bridge | /app/grafana/ | 3000→grafana (new tab) |
|
||||
|
||||
## Self-Hosted Apps
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nextcloud | 8085 | 80 | bridge | /app/nextcloud/ | 8085→nextcloud |
|
||||
| vaultwarden | 8082 | 80 | bridge | /app/vaultwarden/ | 8082→vaultwarden (new tab) |
|
||||
| filebrowser | 8083 | 80 | bridge | /app/filebrowser/ | 8083→filebrowser |
|
||||
| searxng | 8888 | 8080 | bridge | /app/searxng/ | 8888→searxng |
|
||||
| photoprism | 2342 | 2342 | bridge | /app/photoprism/ | 2342→photoprism (new tab) |
|
||||
| jellyfin | 8096 | 8096 | bridge | /app/jellyfin/ | 8096→jellyfin |
|
||||
| homeassistant | 8123 | 8123 | bridge | /app/homeassistant/ | 8123→homeassistant (new tab) |
|
||||
| ollama | 11434 | 11434 | archy-net | /app/ollama/ | 11434→ollama |
|
||||
| open-webui | 3080 | 8080 | archy-net | /app/open-webui/ | 3080→open-webui |
|
||||
|
||||
## Nostr & Social
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| nostr-rs-relay | 7000 | 8080 | archy-net | /app/nostr-rs-relay/ | 7000→nostr-rs-relay |
|
||||
| indeedhub | 3001 | 3000 | archy-net | /app/indeedhub/ | 3001→indeedhub |
|
||||
|
||||
## System
|
||||
|
||||
| App | Host Port(s) | Container Port(s) | Network | Nginx Path | Frontend Map |
|
||||
|-----|-------------|-------------------|---------|------------|-------------|
|
||||
| tailscale | 8240 | 8240 | host | /app/tailscale/ | N/A |
|
||||
| nginx-proxy-manager | 81, 8443 | 81, 443 | bridge | N/A | 81→nginx-proxy-manager |
|
||||
|
||||
## Multi-Container Stacks
|
||||
|
||||
**Immich**: immich-server (2283), immich-postgres (internal 5432), immich-redis (internal 6379) — all on immich-net
|
||||
**Penpot**: penpot-frontend (9001→80), penpot-backend, penpot-exporter, penpot-postgres, penpot-mailcatch — all on penpot-net
|
||||
**Mempool**: mempool (4080→8080), mempool-db (internal 3306) — on archy-net
|
||||
**BTCPay**: btcpay-server (23000→49392), nbxplorer (24444→32838), btcpay-postgres (internal 5432) — on archy-net
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **archy-net apps** resolve each other by container name (e.g., `bitcoin-knots:8332`)
|
||||
- **bridge apps** are standalone — access services via host IP/port
|
||||
- **host network** (tailscale only) — shares host namespace, no port mapping
|
||||
- **New tab apps**: btcpay (23000), grafana (3000), vaultwarden (8082), photoprism (2342), homeassistant (8123) — X-Frame-Options blocks iframe
|
||||
219
.claude/skills/podman-fix/SKILL.md
Normal file
219
.claude/skills/podman-fix/SKILL.md
Normal file
@ -0,0 +1,219 @@
|
||||
---
|
||||
name: podman-fix
|
||||
description: >
|
||||
Fix Podman container issues on Archipelago — restart failed containers, repair port bindings,
|
||||
fix network connectivity, add missing restart policies, and resolve config drift.
|
||||
Use when asked to "fix container", "restart app", "fix port mapping", "container not working",
|
||||
"app won't start", "fix podman", "repair container", "container down", or after /podman-doctor
|
||||
identifies issues to fix.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Fix — Container Remediation
|
||||
|
||||
Targeted fix workflow for Podman container issues on Archipelago. Given a specific problem (from /podman-doctor or user report), diagnose the root cause and fix it.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
If $ARGUMENTS is provided, fix that specific app/issue. Otherwise ask what needs fixing.
|
||||
|
||||
## Fix Procedures
|
||||
|
||||
### Fix 1: Container Not Running
|
||||
|
||||
```bash
|
||||
# Check why it stopped
|
||||
sudo podman logs --tail 50 CONTAINER_NAME
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.State.ExitCode}} {{.State.Error}}"
|
||||
|
||||
# If clean exit or crash — just restart
|
||||
sudo podman start CONTAINER_NAME
|
||||
|
||||
# If corrupt state — remove and recreate
|
||||
sudo podman rm -f CONTAINER_NAME
|
||||
# Then recreate using the install flow (trigger from UI or re-run creation command)
|
||||
```
|
||||
|
||||
**If container keeps crashing**: check logs for the actual error. Common causes:
|
||||
- Missing config file → check if volume mount has the config
|
||||
- Wrong permissions → `chown -R` the data directory
|
||||
- Dependency not ready → start dependency first, wait, then start this container
|
||||
|
||||
### Fix 2: Missing Restart Policy
|
||||
|
||||
The most common uptime killer. Fix for ALL containers at once:
|
||||
|
||||
```bash
|
||||
# Fix a single container
|
||||
sudo podman update --restart unless-stopped CONTAINER_NAME
|
||||
|
||||
# Fix ALL containers that have no restart policy
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing restart policy for: $c"
|
||||
sudo podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Also update the Rust source** so new installs get it right:
|
||||
- Check `core/archipelago/src/api/rpc/package.rs` `get_app_config()` for the app
|
||||
- Ensure `--restart` flag is in the podman run args
|
||||
|
||||
### Fix 3: Port Mapping Issues
|
||||
|
||||
#### Port conflict (address already in use)
|
||||
```bash
|
||||
# Find what's using the port
|
||||
sudo ss -tlnp | grep :PORT_NUMBER
|
||||
|
||||
# If it's another container, either change one's port or stop the conflicting one
|
||||
sudo podman stop CONFLICTING_CONTAINER
|
||||
|
||||
# If it's a host process
|
||||
sudo kill PID # or stop the service
|
||||
```
|
||||
|
||||
#### Port not mapped (container running but port unreachable)
|
||||
```bash
|
||||
# Check current port mappings
|
||||
sudo podman port CONTAINER_NAME
|
||||
|
||||
# Can't add ports to running container — must recreate
|
||||
sudo podman stop CONTAINER_NAME
|
||||
sudo podman rm CONTAINER_NAME
|
||||
# Recreate with correct -p flags (use the Rust install flow or manual podman run)
|
||||
```
|
||||
|
||||
#### Nginx proxy missing or wrong
|
||||
Read and fix the nginx config:
|
||||
- HTTP: `image-recipe/configs/nginx-archipelago.conf`
|
||||
- HTTPS: `image-recipe/configs/snippets/archipelago-https-app-proxies.conf`
|
||||
|
||||
Add a location block:
|
||||
```nginx
|
||||
location /app/APP_ID/ {
|
||||
proxy_pass http://127.0.0.1:HOST_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# Hide X-Frame-Options so it works in our iframe
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
```
|
||||
|
||||
After editing nginx config, deploy and reload:
|
||||
```bash
|
||||
# On server
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### Frontend routing missing
|
||||
Edit `neode-ui/src/stores/appLauncher.ts`:
|
||||
- Add entry to `PORT_TO_APP_ID` map
|
||||
- If app blocks iframes, add port to the new-tab list in `resolveAppIdFromUrl()`
|
||||
|
||||
### Fix 4: Network Issues
|
||||
|
||||
#### Container not on archy-net (can't resolve other containers)
|
||||
```bash
|
||||
# Connect to archy-net without recreating
|
||||
sudo podman network connect archy-net CONTAINER_NAME
|
||||
|
||||
# Verify
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.NetworkSettings.Networks}}"
|
||||
```
|
||||
|
||||
#### archy-net doesn't exist
|
||||
```bash
|
||||
sudo podman network create archy-net
|
||||
# Then reconnect all containers that need it
|
||||
```
|
||||
|
||||
#### DNS not working inside container
|
||||
```bash
|
||||
# Test DNS from inside container
|
||||
sudo podman exec CONTAINER_NAME nslookup bitcoin-knots 2>/dev/null || \
|
||||
sudo podman exec CONTAINER_NAME ping -c1 bitcoin-knots
|
||||
|
||||
# If DNS fails, recreate container with explicit DNS
|
||||
# Add --dns 1.1.1.1 to the podman run command
|
||||
```
|
||||
|
||||
### Fix 5: Health Check Issues
|
||||
|
||||
#### Add missing health check to running container
|
||||
Can't add to running container — must recreate with health check flags:
|
||||
```bash
|
||||
# Example for a web app
|
||||
sudo podman run ... \
|
||||
--health-cmd "curl -f http://localhost:PORT/health || exit 1" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 60s \
|
||||
IMAGE
|
||||
```
|
||||
|
||||
#### Fix unhealthy container
|
||||
```bash
|
||||
# See what the health check is actually running
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.Config.Healthcheck.Test}}"
|
||||
|
||||
# Run the health check manually to see the error
|
||||
sudo podman exec CONTAINER_NAME HEALTH_CHECK_COMMAND
|
||||
|
||||
# Common fixes:
|
||||
# - curl not installed in container → use wget or nc instead
|
||||
# - Wrong port in health check → fix the check command
|
||||
# - App takes too long to start → increase --health-start-period
|
||||
```
|
||||
|
||||
### Fix 6: Permission/Capability Issues
|
||||
|
||||
```bash
|
||||
# Check what capabilities container has
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.HostConfig.CapAdd}}"
|
||||
|
||||
# If missing required caps, must recreate with correct --cap-add flags
|
||||
# Refer to the capability reference in /podman-doctor references
|
||||
|
||||
# Fix data directory permissions
|
||||
sudo chown -R 1000:1000 /var/lib/archipelago/APP_NAME/
|
||||
```
|
||||
|
||||
### Fix 7: Full Config Consistency Fix
|
||||
|
||||
When port map is inconsistent across layers, fix ALL layers:
|
||||
|
||||
1. **Decide the correct port** (usually what's in package.rs)
|
||||
2. **Fix Podman**: recreate container with correct `-p` flags
|
||||
3. **Fix Nginx**: update location block's `proxy_pass` port
|
||||
4. **Fix Frontend**: update `PORT_TO_APP_ID` in appLauncher.ts
|
||||
5. **Deploy**: `./scripts/deploy-to-target.sh --live`
|
||||
6. **Verify**: `curl -I http://192.168.1.228/app/APP_ID/`
|
||||
|
||||
## After Fixing
|
||||
|
||||
Always verify the fix:
|
||||
```bash
|
||||
# Container running?
|
||||
sudo podman ps --filter name=CONTAINER_NAME
|
||||
|
||||
# Port reachable?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:PORT/
|
||||
|
||||
# Via nginx proxy?
|
||||
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/app/APP_ID/
|
||||
|
||||
# Health check passing?
|
||||
sudo podman inspect CONTAINER_NAME --format "{{.State.Health.Status}}"
|
||||
```
|
||||
|
||||
Run `/podman-doctor` again to confirm all issues are resolved.
|
||||
309
.claude/skills/podman-uptime/SKILL.md
Normal file
309
.claude/skills/podman-uptime/SKILL.md
Normal file
@ -0,0 +1,309 @@
|
||||
---
|
||||
name: podman-uptime
|
||||
description: >
|
||||
Ensure 100% container uptime on Archipelago. Sets up systemd watchdog timers, verifies
|
||||
restart policies, creates health check monitors, and configures auto-recovery for all
|
||||
containers. Use when asked to "ensure uptime", "containers keep dying", "auto-restart",
|
||||
"watchdog", "container monitoring", "uptime guarantee", "keep containers running",
|
||||
"survive reboot", or to harden container reliability.
|
||||
allowed-tools: Bash Read Edit Write Glob Grep
|
||||
---
|
||||
|
||||
# Podman Uptime — Container Reliability Guardian
|
||||
|
||||
Ensures every Archipelago container survives reboots, recovers from crashes, and stays healthy. Sets up the three layers of uptime defense: restart policies, systemd watchdog, and health-based auto-recovery.
|
||||
|
||||
**SSH command**: `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`
|
||||
|
||||
## Layer 1: Restart Policies (Survive Reboots)
|
||||
|
||||
Every container MUST have `--restart unless-stopped`. This is non-negotiable.
|
||||
|
||||
### Audit and fix all containers
|
||||
|
||||
```bash
|
||||
# Audit
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo "$c: $policy"
|
||||
done
|
||||
|
||||
# Fix any with "no" or empty policy
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
if [ "$policy" = "no" ] || [ -z "$policy" ]; then
|
||||
echo "Fixing: $c"
|
||||
sudo podman update --restart unless-stopped "$c"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Ensure podman auto-starts containers on boot
|
||||
|
||||
```bash
|
||||
# Enable podman-restart service (restarts containers with restart policy on boot)
|
||||
sudo systemctl enable podman-restart.service 2>/dev/null || true
|
||||
|
||||
# If podman-restart doesn't exist, create it
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/podman-restart.service
|
||||
[Unit]
|
||||
Description=Podman Start All Containers With Restart Policy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/podman start --all --filter restart-policy=unless-stopped
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable podman-restart.service
|
||||
```
|
||||
|
||||
## Layer 2: Systemd Watchdog (Detect and Recover)
|
||||
|
||||
Create a systemd timer that checks container health every 2 minutes and restarts unhealthy or stopped containers.
|
||||
|
||||
### Create the watchdog script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-container-watchdog.sh
|
||||
#!/bin/bash
|
||||
# Archipelago Container Watchdog
|
||||
# Checks all containers and restarts any that are stopped or unhealthy
|
||||
|
||||
LOG_TAG="container-watchdog"
|
||||
|
||||
# Restart any stopped containers that should be running (have restart policy)
|
||||
for c in $(sudo podman ps -a --filter status=exited --filter restart-policy=unless-stopped --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Restarting stopped container: $c"
|
||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Restart unhealthy containers
|
||||
for c in $(sudo podman ps --filter health=unhealthy --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Restarting unhealthy container: $c"
|
||||
sudo podman restart "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
|
||||
# Check for containers in "created" state (never started)
|
||||
for c in $(sudo podman ps -a --filter status=created --format "{{.Names}}"); do
|
||||
logger -t "$LOG_TAG" "Starting created container: $c"
|
||||
sudo podman start "$c" 2>&1 | logger -t "$LOG_TAG"
|
||||
done
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-container-watchdog.sh
|
||||
```
|
||||
|
||||
### Create the systemd timer
|
||||
|
||||
```bash
|
||||
# Service unit
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.service
|
||||
[Unit]
|
||||
Description=Archipelago Container Watchdog
|
||||
After=podman-restart.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/archipelago-container-watchdog.sh
|
||||
EOF
|
||||
|
||||
# Timer unit — runs every 2 minutes
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-watchdog.timer
|
||||
[Unit]
|
||||
Description=Run Archipelago Container Watchdog every 2 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=120
|
||||
OnUnitActiveSec=120
|
||||
AccuracySec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now archipelago-watchdog.timer
|
||||
```
|
||||
|
||||
### Verify watchdog is running
|
||||
|
||||
```bash
|
||||
sudo systemctl status archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
# Check watchdog logs
|
||||
sudo journalctl -t container-watchdog --since "1 hour ago" --no-pager
|
||||
```
|
||||
|
||||
## Layer 3: Dependency-Aware Startup Order
|
||||
|
||||
Some containers depend on others. The watchdog handles restarts, but initial boot order matters.
|
||||
|
||||
### Create ordered startup script
|
||||
|
||||
```bash
|
||||
cat <<'SCRIPT' | sudo tee /usr/local/bin/archipelago-ordered-start.sh
|
||||
#!/bin/bash
|
||||
# Ordered container startup for Archipelago
|
||||
# Respects dependency chain: bitcoin → electrs/lnd → mempool/btcpay
|
||||
|
||||
LOG_TAG="ordered-start"
|
||||
|
||||
wait_for_container() {
|
||||
local name=$1
|
||||
local max_wait=${2:-60}
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
status=$(sudo podman inspect "$name" --format "{{.State.Running}}" 2>/dev/null)
|
||||
if [ "$status" = "true" ]; then
|
||||
logger -t "$LOG_TAG" "$name is running"
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
logger -t "$LOG_TAG" "WARNING: $name not running after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Tier 0: Infrastructure
|
||||
logger -t "$LOG_TAG" "Starting Tier 0: Infrastructure"
|
||||
sudo podman start tailscale 2>/dev/null
|
||||
|
||||
# Tier 1: Bitcoin (foundation)
|
||||
logger -t "$LOG_TAG" "Starting Tier 1: Bitcoin"
|
||||
sudo podman start bitcoin-knots 2>/dev/null
|
||||
wait_for_container bitcoin-knots 120
|
||||
|
||||
# Tier 2: Bitcoin-dependent services
|
||||
logger -t "$LOG_TAG" "Starting Tier 2: Bitcoin-dependent"
|
||||
sudo podman start electrs 2>/dev/null
|
||||
sudo podman start lnd 2>/dev/null
|
||||
wait_for_container electrs 90
|
||||
wait_for_container lnd 90
|
||||
|
||||
# Tier 3: Services depending on Tier 2
|
||||
logger -t "$LOG_TAG" "Starting Tier 3: Second-order dependencies"
|
||||
sudo podman start mempool-db 2>/dev/null
|
||||
sleep 5
|
||||
sudo podman start mempool 2>/dev/null
|
||||
sudo podman start nbxplorer 2>/dev/null
|
||||
sleep 10
|
||||
sudo podman start btcpay-server 2>/dev/null
|
||||
sudo podman start btcpay-postgres 2>/dev/null
|
||||
|
||||
# Tier 4: Independent apps (start all remaining)
|
||||
logger -t "$LOG_TAG" "Starting Tier 4: Independent apps"
|
||||
sudo podman start --all 2>/dev/null
|
||||
|
||||
# Tier 5: UI containers (need parent apps running first)
|
||||
logger -t "$LOG_TAG" "Starting Tier 5: UI containers"
|
||||
sudo podman start bitcoin-ui 2>/dev/null
|
||||
sudo podman start lnd-ui 2>/dev/null
|
||||
|
||||
logger -t "$LOG_TAG" "Startup sequence complete"
|
||||
SCRIPT
|
||||
|
||||
sudo chmod +x /usr/local/bin/archipelago-ordered-start.sh
|
||||
```
|
||||
|
||||
### Wire into boot sequence
|
||||
|
||||
```bash
|
||||
cat <<'EOF' | sudo tee /etc/systemd/system/archipelago-containers.service
|
||||
[Unit]
|
||||
Description=Archipelago Ordered Container Startup
|
||||
After=network-online.target podman.service
|
||||
Wants=network-online.target
|
||||
Before=archipelago.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/archipelago-ordered-start.sh
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable archipelago-containers.service
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After setting up all 3 layers, verify:
|
||||
|
||||
```bash
|
||||
echo "=== Layer 1: Restart Policies ==="
|
||||
for c in $(sudo podman ps -a --format "{{.Names}}"); do
|
||||
policy=$(sudo podman inspect "$c" --format "{{.HostConfig.RestartPolicy.Name}}")
|
||||
echo " $c: $policy"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 2: Watchdog Timer ==="
|
||||
sudo systemctl is-active archipelago-watchdog.timer
|
||||
sudo systemctl list-timers | grep archipelago
|
||||
|
||||
echo ""
|
||||
echo "=== Layer 3: Boot Services ==="
|
||||
sudo systemctl is-enabled podman-restart.service 2>/dev/null || echo "podman-restart: not found"
|
||||
sudo systemctl is-enabled archipelago-containers.service 2>/dev/null || echo "ordered-start: not found"
|
||||
sudo systemctl is-enabled archipelago-watchdog.timer 2>/dev/null || echo "watchdog: not found"
|
||||
|
||||
echo ""
|
||||
echo "=== Container Health Summary ==="
|
||||
total=$(sudo podman ps -a --format "{{.Names}}" | wc -l)
|
||||
running=$(sudo podman ps --format "{{.Names}}" | wc -l)
|
||||
stopped=$((total - running))
|
||||
unhealthy=$(sudo podman ps --filter health=unhealthy --format "{{.Names}}" | wc -l)
|
||||
echo " Total: $total | Running: $running | Stopped: $stopped | Unhealthy: $unhealthy"
|
||||
```
|
||||
|
||||
## Reboot Test
|
||||
|
||||
The ultimate uptime test — reboot the server and verify everything comes back:
|
||||
|
||||
```bash
|
||||
# Before reboot: record running containers
|
||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/before-reboot.txt
|
||||
|
||||
# Reboot
|
||||
sudo reboot
|
||||
|
||||
# After reboot (wait ~3 minutes, then SSH back in):
|
||||
sudo podman ps --format "{{.Names}}" | sort > /tmp/after-reboot.txt
|
||||
|
||||
# Compare
|
||||
diff /tmp/before-reboot.txt /tmp/after-reboot.txt
|
||||
# Should show no differences
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check uptime status anytime:
|
||||
```bash
|
||||
# Quick status
|
||||
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}" | sort
|
||||
|
||||
# Watchdog activity
|
||||
sudo journalctl -t container-watchdog --since "24 hours ago" --no-pager
|
||||
|
||||
# Container events (starts, stops, deaths)
|
||||
sudo podman events --since 24h --filter event=start --filter event=stop --filter event=died 2>/dev/null | tail -30
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- Run `/podman-doctor` first to identify issues
|
||||
- Run `/podman-fix` for specific container repairs
|
||||
- Run `/podman-uptime` to set up permanent reliability infrastructure
|
||||
- Add to ISO build: copy watchdog scripts to `image-recipe/configs/` and enable in first-boot
|
||||
@ -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;
|
||||
|
||||
@ -176,23 +176,58 @@ impl RpcHandler {
|
||||
|
||||
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Determine lan_address based on container name
|
||||
// Map container name to its UI port (lan_address)
|
||||
let lan_address = match name {
|
||||
"bitcoin-knots" => Some("http://localhost:8334"),
|
||||
"lnd" => Some("http://localhost:8081"),
|
||||
"bitcoin-knots" | "bitcoin-ui" => Some("http://localhost:8334"),
|
||||
"lnd" | "archy-lnd-ui" => Some("http://localhost:8081"),
|
||||
"tailscale" => Some("http://localhost:8240"),
|
||||
"homeassistant" => Some("http://localhost:8123"),
|
||||
"archy-mempool-web" | "mempool" => Some("http://localhost:4080"),
|
||||
"btcpay-server" => Some("http://localhost:23000"),
|
||||
"grafana" => Some("http://localhost:3000"),
|
||||
"searxng" => Some("http://localhost:8888"),
|
||||
"ollama" => Some("http://localhost:11434"),
|
||||
"onlyoffice" => Some("http://localhost:9980"),
|
||||
"penpot" => Some("http://localhost:9001"),
|
||||
"nextcloud" => Some("http://localhost:8085"),
|
||||
"vaultwarden" => Some("http://localhost:8082"),
|
||||
"jellyfin" => Some("http://localhost:8096"),
|
||||
"photoprism" => Some("http://localhost:2342"),
|
||||
"immich_server" | "immich" => Some("http://localhost:2283"),
|
||||
"filebrowser" => Some("http://localhost:8083"),
|
||||
"nginx-proxy-manager" => Some("http://localhost:81"),
|
||||
"portainer" => Some("http://localhost:9000"),
|
||||
"uptime-kuma" => Some("http://localhost:3001"),
|
||||
"fedimint" => Some("http://localhost:8175"),
|
||||
"fedimint-gateway" => Some("http://localhost:8176"),
|
||||
"nostr-rs-relay" => Some("http://localhost:18081"),
|
||||
"indeedhub" => Some("http://localhost:7777"),
|
||||
"dwn" => Some("http://localhost:3100"),
|
||||
"endurain" => Some("http://localhost:8080"),
|
||||
"electrs" | "archy-electrs-ui" => Some("http://localhost:50002"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Parse ports from podman JSON (field is "host_port" in snake_case)
|
||||
let ports: Vec<String> = c.get("Ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter().filter_map(|p| {
|
||||
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
serde_json::json!({
|
||||
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"name": name,
|
||||
"state": mapped_state,
|
||||
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
|
||||
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
|
||||
).unwrap_or_default(),
|
||||
"ports": ports,
|
||||
"lan_address": lan_address,
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! RPC handlers for multi-identity management.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::identity_manager::{IdentityManager, IdentityPurpose};
|
||||
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
|
||||
use crate::network::did_dht;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
@ -43,6 +43,7 @@ impl RpcHandler {
|
||||
"is_default": is_default,
|
||||
"nostr_pubkey": id.nostr_pubkey,
|
||||
"nostr_npub": id.nostr_npub,
|
||||
"profile": id.profile,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@ -650,6 +651,94 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update profile metadata for an identity.
|
||||
pub(super) async fn handle_identity_update_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let profile = IdentityProfile {
|
||||
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
|
||||
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
|
||||
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
|
||||
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
|
||||
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
|
||||
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
|
||||
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
|
||||
};
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.update_profile(id, profile).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||
pub(super) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_url = params.get("relay")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ws://localhost:18081");
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let event_id = manager.publish_profile(id, relay_url).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"event_id": event_id,
|
||||
"relay": relay_url,
|
||||
"published": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Export private keys for an identity — REQUIRES password verification.
|
||||
pub(super) async fn handle_identity_export_keys(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
// Verify password against auth system
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Invalid password");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let keys = manager.export_keys(id).await?;
|
||||
let record = manager.get(id).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"pubkey": record.pubkey_hex,
|
||||
"did": record.did,
|
||||
"nostr_pubkey": record.nostr_pubkey,
|
||||
"nostr_npub": record.nostr_npub,
|
||||
"ed25519_secret_hex": keys["ed25519_secret_hex"],
|
||||
"nostr_secret_hex": keys["nostr_secret_hex"],
|
||||
"nostr_nsec": keys["nostr_nsec"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// identity.dht-status — Check if an identity's did:dht is published and resolvable.
|
||||
pub(super) async fn handle_identity_dht_status(
|
||||
&self,
|
||||
|
||||
@ -674,6 +674,129 @@ impl RpcHandler {
|
||||
"broadcast": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx
|
||||
.get("block_height")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Channel types
|
||||
|
||||
@ -35,7 +35,7 @@ use crate::config::Config;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore};
|
||||
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore, REMEMBER_TTL};
|
||||
use crate::state::StateManager;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
@ -221,12 +221,30 @@ impl RpcHandler {
|
||||
|
||||
// Enforce authentication for non-allowlisted methods
|
||||
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
|
||||
let mut new_session_cookies: Option<(String, String)> = None; // (session, csrf) if auto-restored
|
||||
if !is_unauthenticated {
|
||||
let authenticated = match &session_token {
|
||||
let mut authenticated = match &session_token {
|
||||
Some(token) => self.session_store.validate(token).await,
|
||||
None => false,
|
||||
};
|
||||
|
||||
// If session invalid, try remember-me token to auto-restore session
|
||||
if !authenticated {
|
||||
if let Some(remember) = extract_cookie(&parts.headers, "remember") {
|
||||
if crate::session::SessionStore::validate_remember_token(&remember) {
|
||||
// Auto-create a new session from the remember-me token
|
||||
let new_token = self.session_store.create().await;
|
||||
let new_csrf = generate_csrf_token();
|
||||
tracing::info!("Auto-restored session from remember-me token");
|
||||
new_session_cookies = Some((new_token, new_csrf));
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" };
|
||||
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
@ -269,7 +287,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// CSRF protection: validate X-CSRF-Token header for authenticated methods
|
||||
if !is_unauthenticated {
|
||||
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response)
|
||||
if !is_unauthenticated && new_session_cookies.is_none() {
|
||||
let csrf_cookie = extract_csrf_cookie(&parts.headers);
|
||||
let csrf_header = parts
|
||||
.headers
|
||||
@ -280,6 +299,12 @@ impl RpcHandler {
|
||||
match (&csrf_cookie, &csrf_header) {
|
||||
(Some(cookie), Some(header)) if cookie == header => { /* valid */ }
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
method = %rpc_req.method,
|
||||
has_cookie = csrf_cookie.is_some(),
|
||||
has_header = csrf_header.is_some(),
|
||||
"403 CSRF mismatch — rejecting RPC call"
|
||||
);
|
||||
let rpc_resp = RpcResponse {
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
@ -445,6 +470,7 @@ impl RpcHandler {
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
@ -461,6 +487,9 @@ impl RpcHandler {
|
||||
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||
"identity.dht-status" => self.handle_identity_dht_status(params).await,
|
||||
"identity.update-profile" => self.handle_identity_update_profile(params).await,
|
||||
"identity.publish-profile" => self.handle_identity_publish_profile(params).await,
|
||||
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
|
||||
@ -778,6 +807,7 @@ impl RpcHandler {
|
||||
// No 2FA: create a full session immediately
|
||||
let token = self.session_store.create().await;
|
||||
let csrf_token = generate_csrf_token();
|
||||
let remember_token = self.session_store.create_remember_token();
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
||||
@ -790,6 +820,13 @@ impl RpcHandler {
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// Remember-me: HMAC-signed, survives backend restarts (30-day TTL)
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, self.cookie_suffix())
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -844,6 +881,23 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// If session was auto-restored from remember-me, set new cookies on the response
|
||||
if let Some((new_session, new_csrf)) = new_session_cookies {
|
||||
let suffix = self.cookie_suffix();
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", new_session, suffix)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!("csrf_token={}; SameSite=Strict; Path=/{}", new_csrf, suffix)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@ -864,13 +918,14 @@ fn generate_csrf_token() -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Extract the csrf_token cookie value from headers.
|
||||
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
/// Extract a named cookie value from headers.
|
||||
fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", name);
|
||||
for value in headers.get_all("cookie") {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for part in s.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(val) = part.strip_prefix("csrf_token=") {
|
||||
if let Some(val) = part.strip_prefix(&prefix) {
|
||||
let val = val.trim();
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
@ -882,6 +937,11 @@ fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the csrf_token cookie value from headers.
|
||||
fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
extract_cookie(headers, "csrf_token")
|
||||
}
|
||||
|
||||
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||
headers
|
||||
|
||||
@ -58,13 +58,13 @@ impl RpcHandler {
|
||||
})
|
||||
};
|
||||
let has_bitcoin = is_running(&["bitcoin-knots", "bitcoin-core", "bitcoin"]);
|
||||
let has_electrs = is_running(&["mempool-electrs", "electrs"]);
|
||||
let has_electrumx = is_running(&["electrumx", "mempool-electrs", "electrs"]);
|
||||
has_lnd = is_running(&["lnd"]);
|
||||
|
||||
match package_id {
|
||||
"mempool-electrs" | "electrs" if !has_bitcoin => {
|
||||
"electrumx" | "mempool-electrs" | "electrs" if !has_bitcoin => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Electrs requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
));
|
||||
}
|
||||
"lnd" if !has_bitcoin => {
|
||||
@ -77,10 +77,10 @@ impl RpcHandler {
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). Please install and start Bitcoin Knots first."
|
||||
));
|
||||
}
|
||||
"mempool" | "mempool-web" if !has_bitcoin || !has_electrs => {
|
||||
"mempool" | "mempool-web" if !has_bitcoin || !has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !has_bitcoin { missing.push("Bitcoin Knots"); }
|
||||
if !has_electrs { missing.push("Electrs"); }
|
||||
if !has_electrumx { missing.push("ElectrumX"); }
|
||||
return Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
@ -179,8 +179,11 @@ impl RpcHandler {
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
// Normalize container name: "electrs" alias -> "mempool-electrs"
|
||||
let container_name = if package_id == "electrs" { "mempool-electrs" } else { package_id };
|
||||
// Normalize container name: legacy "electrs"/"mempool-electrs" aliases -> "electrumx"
|
||||
let container_name = match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
};
|
||||
|
||||
// Create and start container with security constraints
|
||||
let mut run_args = vec![
|
||||
@ -234,7 +237,7 @@ impl RpcHandler {
|
||||
package_id,
|
||||
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
|
||||
| "lnd"
|
||||
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
||||
| "mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
|
||||
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db" | "archy-nbxplorer" | "nbxplorer"
|
||||
| "fedimint" | "fedimint-gateway"
|
||||
);
|
||||
@ -669,10 +672,10 @@ printtoconsole=1\n";
|
||||
vec![format!("archy-{}", package_id)]
|
||||
} else {
|
||||
let order: &[&str] = match package_id {
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"penpot" | "penpot-frontend" => &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"],
|
||||
_ => &["archy-mempool-db", "mysql-mempool", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
_ => &["archy-mempool-db", "mysql-mempool", "electrumx", "mempool-electrs", "mempool-api", "archy-mempool-api", "archy-mempool-web", "mempool"],
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| order.iter().position(|o| *o == c).unwrap_or(99));
|
||||
@ -1114,6 +1117,7 @@ async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
let patterns: Vec<String> = match package_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
vec![
|
||||
"electrumx".into(),
|
||||
"mempool-electrs".into(),
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-api".into(),
|
||||
@ -1160,6 +1164,7 @@ fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
format!("{}/electrumx", base),
|
||||
format!("{}/mempool-electrs", base),
|
||||
],
|
||||
"fedimint" => vec![format!("{}/fedimint", base), format!("{}/fedimint-gateway", base)],
|
||||
@ -1300,6 +1305,7 @@ fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
"searxng"
|
||||
| "grafana"
|
||||
| "filebrowser"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "nostr-rs-relay"
|
||||
@ -1332,8 +1338,8 @@ fn get_health_check_args(app_id: &str) -> Vec<String> {
|
||||
"curl -sf http://localhost:8080/ || exit 1",
|
||||
"30s", "3",
|
||||
),
|
||||
"mempool-electrs" | "electrs" => (
|
||||
"curl -sf http://localhost:50001/ || exit 1",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => (
|
||||
"curl -sf http://localhost:8000/ || exit 1",
|
||||
"60s", "3",
|
||||
),
|
||||
"nextcloud" => (
|
||||
@ -1420,7 +1426,7 @@ fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
"lnd" => "512m",
|
||||
"mempool-electrs" | "electrs" => "1g",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
@ -1507,7 +1513,7 @@ fn get_app_config(
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=mempool-electrs".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
@ -1523,26 +1529,20 @@ fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-electrs" | "electrs" => {
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
// Detect which bitcoin container is running for archy-net DNS resolution
|
||||
let bitcoin_host = detect_bitcoin_container_name();
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool-electrs:/data".to_string()],
|
||||
vec![],
|
||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||
vec![
|
||||
format!("DAEMON_URL=http://archipelago:archipelago123@{}:8332/", bitcoin_host),
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--daemon-rpc-addr".to_string(),
|
||||
format!("{}:8332", bitcoin_host),
|
||||
"--cookie".to_string(),
|
||||
"archipelago:archipelago123".to_string(),
|
||||
"--jsonrpc-import".to_string(),
|
||||
"--electrum-rpc-addr".to_string(),
|
||||
"0.0.0.0:50001".to_string(),
|
||||
"--db-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--lightmode".to_string(),
|
||||
]),
|
||||
)
|
||||
},
|
||||
"mysql-mempool" => (
|
||||
|
||||
@ -592,8 +592,8 @@ async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// system.factory-reset — Wipe all user data and restart.
|
||||
/// Preserves container images and node_key (hardware identity).
|
||||
/// system.factory-reset — Wipe all user data, remove containers, and restart.
|
||||
/// Only preserves the data_dir itself (recreated empty on restart).
|
||||
pub(super) async fn handle_system_factory_reset(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@ -609,53 +609,67 @@ impl RpcHandler {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping user data");
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
// Stop all running containers
|
||||
// 1. Stop and remove ALL containers (force)
|
||||
let client = archipelago_container::PodmanClient::new("archipelago".to_string());
|
||||
if let Ok(containers) = client.list_containers().await {
|
||||
for c in &containers {
|
||||
tracing::info!("Factory reset: removing container {}", c.name);
|
||||
let _ = client.stop_container(&c.name).await;
|
||||
let _ = client.remove_container(&c.name).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user data (preserving node_key and container images)
|
||||
let files_to_remove = [
|
||||
"user.json",
|
||||
"onboarding.json",
|
||||
"peers.json",
|
||||
"server-name",
|
||||
];
|
||||
for f in &files_to_remove {
|
||||
let path = data_dir.join(f);
|
||||
if path.exists() {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
// 2. Remove all container images
|
||||
tracing::info!("Factory reset: pruning all container images");
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 4. Wipe the entire data directory contents
|
||||
// Delete everything inside data_dir, then recreate the empty dir.
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(data_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Skip the tor directory (managed by system debian-tor user)
|
||||
if name_str == "tor" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("Factory reset: removing {}", path.display());
|
||||
if path.is_dir() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dirs_to_remove = [
|
||||
"identities",
|
||||
"credentials",
|
||||
"did-cache",
|
||||
"dwn",
|
||||
];
|
||||
for d in &dirs_to_remove {
|
||||
let path = data_dir.join(d);
|
||||
if path.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all sessions
|
||||
// 5. Clear all sessions
|
||||
self.session_store.invalidate_all_except("").await;
|
||||
|
||||
tracing::warn!("Factory reset complete — restarting service");
|
||||
tracing::warn!("Factory reset complete — all data wiped, restarting service");
|
||||
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "archipelago"])
|
||||
.spawn();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
//! Electrs sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
|
||||
//! ElectrumX sync status: fetches indexed height from Electrum RPC and network height from Bitcoin Core.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
@ -6,12 +6,12 @@ use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
const ELECTRS_HOST: &str = "127.0.0.1";
|
||||
const ELECTRS_PORT: u16 = 50001;
|
||||
const ELECTRUMX_HOST: &str = "127.0.0.1";
|
||||
const ELECTRUMX_PORT: u16 = 50001;
|
||||
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||
const ELECTRS_DATA_DIR: &str = "/var/lib/archipelago/mempool-electrs";
|
||||
// Approximate final index size in bytes for mainnet with --lightmode (~35GB)
|
||||
const ESTIMATED_FULL_INDEX_BYTES: f64 = 35_000_000_000.0;
|
||||
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
|
||||
// Approximate final index size in bytes for mainnet (~55GB for ElectrumX full index)
|
||||
const ESTIMATED_FULL_INDEX_BYTES: f64 = 55_000_000_000.0;
|
||||
|
||||
/// Build Bitcoin RPC Basic auth header from env vars.
|
||||
/// Falls back to cookie auth file if env vars are not set.
|
||||
@ -61,10 +61,10 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch electrs indexed height via Electrum protocol (TCP JSON-RPC).
|
||||
fn electrs_indexed_height() -> Result<u64> {
|
||||
let mut stream = TcpStream::connect((ELECTRS_HOST, ELECTRS_PORT))
|
||||
.context("Failed to connect to electrs")?;
|
||||
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
|
||||
fn electrumx_indexed_height() -> Result<u64> {
|
||||
let mut stream = TcpStream::connect((ELECTRUMX_HOST, ELECTRUMX_PORT))
|
||||
.context("Failed to connect to ElectrumX")?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.context("set_read_timeout")?;
|
||||
@ -83,11 +83,11 @@ fn electrs_indexed_height() -> Result<u64> {
|
||||
reader.read_line(&mut line)?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
anyhow::bail!("Empty response from electrs");
|
||||
anyhow::bail!("Empty response from ElectrumX");
|
||||
}
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(line)?;
|
||||
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N}
|
||||
// blockchain.numblocks.subscribe returns result as number; headers.subscribe returns {block_height: N, hex: ...}
|
||||
let height = json
|
||||
.get("result")
|
||||
.and_then(|r| r.as_u64())
|
||||
@ -96,7 +96,13 @@ fn electrs_indexed_height() -> Result<u64> {
|
||||
.and_then(|r| r.get("block_height"))
|
||||
.and_then(|h| h.as_u64())
|
||||
})
|
||||
.context("Missing height in electrs response")?;
|
||||
.or_else(|| {
|
||||
// ElectrumX returns {"result": {"height": N, "hex": "..."}}
|
||||
json.get("result")
|
||||
.and_then(|r| r.get("height"))
|
||||
.and_then(|h| h.as_u64())
|
||||
})
|
||||
.context("Missing height in ElectrumX response")?;
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
@ -130,10 +136,10 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Get electrs sync status. Runs blocking electrs call in spawn_blocking.
|
||||
/// Get ElectrumX sync status. Runs blocking ElectrumX call in spawn_blocking.
|
||||
pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
// Get index data size (non-blocking, fast filesystem stat)
|
||||
let data_bytes = dir_size_bytes(ELECTRS_DATA_DIR);
|
||||
let data_bytes = dir_size_bytes(ELECTRUMX_DATA_DIR);
|
||||
let index_size = if data_bytes > 0 {
|
||||
Some(format_bytes(data_bytes))
|
||||
} else {
|
||||
@ -154,10 +160,10 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let indexed_height = match tokio::task::spawn_blocking(electrs_indexed_height).await {
|
||||
let indexed_height = match tokio::task::spawn_blocking(electrumx_indexed_height).await {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
// Electrs doesn't listen on 50001 until indexing completes (can take hours)
|
||||
// ElectrumX may not be ready on 50001 during initial sync
|
||||
let err_msg = e.to_string();
|
||||
let (status, error) = if err_msg.contains("connect") || err_msg.contains("Connection refused") {
|
||||
// Estimate progress from data directory size
|
||||
@ -170,12 +176,12 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
(
|
||||
"indexing".to_string(),
|
||||
Some(format!(
|
||||
"Building index ({} / ~35 GB estimated). Electrum RPC will be available when complete.",
|
||||
"Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.",
|
||||
size_str
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
("error".to_string(), Some(format!("Electrs: {}", e)))
|
||||
("error".to_string(), Some(format!("ElectrumX: {}", e)))
|
||||
};
|
||||
// Use estimated progress when indexing
|
||||
let progress_pct = if status == "indexing" && data_bytes > 0 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -48,6 +48,28 @@ pub struct IdentityRecord {
|
||||
pub nostr_pubkey: Option<String>,
|
||||
/// Nostr public key in bech32 npub format (NIP-19)
|
||||
pub nostr_npub: Option<String>,
|
||||
/// Nostr profile metadata (NIP-01 kind 0)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<IdentityProfile>,
|
||||
}
|
||||
|
||||
/// Nostr profile metadata fields (NIP-01 kind 0 + NIP-24 extra fields).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IdentityProfile {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub about: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nip05: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lud16: Option<String>,
|
||||
}
|
||||
|
||||
/// On-disk format for identity storage (includes secret key bytes).
|
||||
@ -64,6 +86,9 @@ struct IdentityFile {
|
||||
nostr_secret_hex: Option<String>,
|
||||
#[serde(default)]
|
||||
nostr_pubkey_hex: Option<String>,
|
||||
/// Nostr profile metadata
|
||||
#[serde(default)]
|
||||
profile: Option<IdentityProfile>,
|
||||
}
|
||||
|
||||
pub struct IdentityManager {
|
||||
@ -123,6 +148,7 @@ impl IdentityManager {
|
||||
created_at: created_at.clone(),
|
||||
nostr_secret_hex: None,
|
||||
nostr_pubkey_hex: None,
|
||||
profile: None,
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
@ -345,6 +371,77 @@ impl IdentityManager {
|
||||
.context("NIP-44 decryption failed")
|
||||
}
|
||||
|
||||
/// Update the profile metadata for an identity.
|
||||
pub async fn update_profile(&self, id: &str, profile: IdentityProfile) -> Result<()> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
if !file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
||||
}
|
||||
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
||||
let mut file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
||||
file.profile = Some(profile);
|
||||
let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?;
|
||||
fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) event to a Nostr relay.
|
||||
pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result<String> {
|
||||
let record = self.get(id).await?;
|
||||
let keys = self.load_nostr_keys(id).await?;
|
||||
let profile = record.profile.unwrap_or_default();
|
||||
|
||||
// Build kind 0 content JSON (NIP-01 + NIP-24)
|
||||
let mut content = serde_json::Map::new();
|
||||
content.insert("name".to_string(), serde_json::json!(record.name));
|
||||
if let Some(v) = &profile.display_name { content.insert("display_name".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.about { content.insert("about".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.picture { content.insert("picture".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.banner { content.insert("banner".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.website { content.insert("website".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.nip05 { content.insert("nip05".to_string(), serde_json::json!(v)); }
|
||||
if let Some(v) = &profile.lud16 { content.insert("lud16".to_string(), serde_json::json!(v)); }
|
||||
|
||||
let content_str = serde_json::to_string(&content).context("Failed to serialize profile content")?;
|
||||
|
||||
let client = nostr_sdk::Client::new(keys);
|
||||
client.add_relay(relay_url).await.context("Failed to add relay")?;
|
||||
client.connect().await;
|
||||
|
||||
let builder = nostr_sdk::prelude::EventBuilder::new(
|
||||
nostr_sdk::prelude::Kind::Metadata,
|
||||
&content_str,
|
||||
);
|
||||
let output = client.send_event_builder(builder).await.context("Failed to publish profile")?;
|
||||
client.disconnect().await;
|
||||
|
||||
Ok(output.id().to_hex())
|
||||
}
|
||||
|
||||
/// Export all keys for an identity (SENSITIVE — only call after password verification).
|
||||
pub async fn export_keys(&self, id: &str) -> Result<serde_json::Value> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
if !file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Identity not found: {}", id));
|
||||
}
|
||||
let data = fs::read(&file_path).await.context("Failed to read identity file")?;
|
||||
let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?;
|
||||
|
||||
let ed25519_secret_hex = hex::encode(&file.secret_key);
|
||||
|
||||
let nostr_nsec = file.nostr_secret_hex.as_ref().and_then(|h| {
|
||||
nostr_sdk::SecretKey::from_hex(h)
|
||||
.ok()
|
||||
.and_then(|sk| sk.to_bech32().ok())
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ed25519_secret_hex": ed25519_secret_hex,
|
||||
"nostr_secret_hex": file.nostr_secret_hex,
|
||||
"nostr_nsec": nostr_nsec,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
}
|
||||
@ -395,6 +492,7 @@ impl IdentityManager {
|
||||
created_at: file.created_at,
|
||||
nostr_pubkey: file.nostr_pubkey_hex,
|
||||
nostr_npub,
|
||||
profile: file.profile,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
|
||||
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
||||
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 20;
|
||||
const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json";
|
||||
const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret";
|
||||
pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SessionType {
|
||||
@ -38,13 +45,106 @@ struct Session {
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedSession {
|
||||
hash_hex: String,
|
||||
created_at: u64, // Unix timestamp
|
||||
last_activity: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
let persist_path = PathBuf::from(SESSIONS_FILE);
|
||||
let sessions = Self::load_from_disk(&persist_path);
|
||||
let count = sessions.len();
|
||||
if count > 0 {
|
||||
tracing::info!("Restored {} sessions from disk", count);
|
||||
}
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted sessions from disk (only Full sessions).
|
||||
fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> {
|
||||
let mut map = HashMap::new();
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return map,
|
||||
};
|
||||
let persisted: Vec<PersistedSession> = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse sessions file: {}", e);
|
||||
return map;
|
||||
}
|
||||
};
|
||||
let now_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
for p in persisted {
|
||||
// Skip expired sessions
|
||||
if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL {
|
||||
continue;
|
||||
}
|
||||
let hash = match hex::decode(&p.hash_hex) {
|
||||
Ok(h) if h.len() == 32 => {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&h);
|
||||
arr
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at);
|
||||
let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity);
|
||||
map.insert(hash, Session {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
});
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Save all Full sessions to disk. Called after mutations.
|
||||
fn save_to_disk_sync(sessions: &HashMap<[u8; 32], Session>, path: &Path) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper for save — spawns to avoid blocking RPC.
|
||||
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
let path = self.persist_path.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = tokio::fs::write(path, json).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a full (authenticated) session. Returns the plaintext token.
|
||||
@ -63,6 +163,9 @@ impl SessionStore {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
self.evict_if_over_limit(&mut sessions);
|
||||
sessions.insert(hash, session);
|
||||
// Sync save — must complete before returning the token to the client.
|
||||
// Async save risks losing the session if the process is killed (e.g., deploy restart).
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
token
|
||||
}
|
||||
|
||||
@ -140,12 +243,15 @@ impl SessionStore {
|
||||
let now = SystemTime::now();
|
||||
session.created_at = now;
|
||||
session.last_activity = now;
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, token: &str) {
|
||||
let hash = hash_token(token);
|
||||
self.sessions.write().await.remove(&hash);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(&hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Invalidate all sessions except the one matching the given token.
|
||||
@ -154,6 +260,7 @@ impl SessionStore {
|
||||
let keep_hash = hash_token(keep_token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.retain(|hash, _| *hash == keep_hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Rotate a session: invalidate the old token and create a new one.
|
||||
@ -175,6 +282,7 @@ impl SessionStore {
|
||||
session_type: SessionType::Full,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
new_token
|
||||
}
|
||||
|
||||
@ -222,6 +330,78 @@ impl SessionStore {
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
// ── Remember-me token ──────────────────────────────────────────────
|
||||
// HMAC-signed token that survives backend restarts. Secret is on disk.
|
||||
// Format: "timestamp_hex:hmac_hex"
|
||||
|
||||
/// Create a remember-me token. Returns the cookie value.
|
||||
pub fn create_remember_token(&self) -> String {
|
||||
let secret = Self::load_or_create_remember_secret();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let ts_hex = hex::encode(now.to_be_bytes());
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
format!("{}:{}", ts_hex, sig)
|
||||
}
|
||||
|
||||
/// Validate a remember-me token. Returns true if valid and not expired.
|
||||
pub fn validate_remember_token(token: &str) -> bool {
|
||||
let secret = match std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
Ok(s) if s.len() == 32 => s,
|
||||
_ => return false,
|
||||
};
|
||||
let parts: Vec<&str> = token.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let ts_hex = parts[0];
|
||||
let sig_hex = parts[1];
|
||||
|
||||
// Verify HMAC
|
||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let expected_sig = match hex::decode(sig_hex) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if mac.verify_slice(&expected_sig).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
let ts_bytes = match hex::decode(ts_hex) {
|
||||
Ok(b) if b.len() == 8 => {
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&b);
|
||||
u64::from_be_bytes(arr)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
||||
}
|
||||
|
||||
fn load_or_create_remember_secret() -> Vec<u8> {
|
||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
if secret.len() == 32 {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
let secret: [u8; 32] = rand::random();
|
||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
|
||||
secret.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> [u8; 32] {
|
||||
|
||||
@ -66,6 +66,41 @@ impl PodmanClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map container name to its UI launch URL
|
||||
fn lan_address_for(name: &str) -> Option<String> {
|
||||
let url = match name {
|
||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
|
||||
"tailscale" => "http://localhost:8240",
|
||||
"homeassistant" => "http://localhost:8123",
|
||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||
"btcpay-server" => "http://localhost:23000",
|
||||
"grafana" => "http://localhost:3000",
|
||||
"searxng" => "http://localhost:8888",
|
||||
"ollama" => "http://localhost:11434",
|
||||
"onlyoffice" => "http://localhost:9980",
|
||||
"penpot" => "http://localhost:9001",
|
||||
"nextcloud" => "http://localhost:8085",
|
||||
"vaultwarden" => "http://localhost:8082",
|
||||
"jellyfin" => "http://localhost:8096",
|
||||
"photoprism" => "http://localhost:2342",
|
||||
"immich_server" | "immich" => "http://localhost:2283",
|
||||
"filebrowser" => "http://localhost:8083",
|
||||
"nginx-proxy-manager" => "http://localhost:81",
|
||||
"portainer" => "http://localhost:9000",
|
||||
"uptime-kuma" => "http://localhost:3001",
|
||||
"fedimint" => "http://localhost:8175",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
"nostr-rs-relay" => "http://localhost:18081",
|
||||
"indeedhub" => "http://localhost:7777",
|
||||
"dwn" => "http://localhost:3100",
|
||||
"endurain" => "http://localhost:8080",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
_ => return None,
|
||||
};
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
fn podman_async(&self) -> TokioCommand {
|
||||
// Always use sudo podman to access system-wide containers
|
||||
let mut cmd = TokioCommand::new("sudo");
|
||||
@ -348,6 +383,7 @@ impl PodmanClient {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let lan_address = Self::lan_address_for(&name);
|
||||
result.push(ContainerStatus {
|
||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||
name,
|
||||
@ -355,7 +391,7 @@ impl PodmanClient {
|
||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports,
|
||||
lan_address: None, // Set by docker_packages scanner
|
||||
lan_address,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@ -365,7 +401,7 @@ impl PodmanClient {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if let Ok(container) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
// Handle both Names as array and Names as string
|
||||
let name = if let Some(names_array) = container["Names"].as_array() {
|
||||
@ -373,7 +409,7 @@ impl PodmanClient {
|
||||
} else {
|
||||
container["Names"].as_str().unwrap_or("").to_string()
|
||||
};
|
||||
|
||||
|
||||
// Parse ports from the Ports array
|
||||
let ports = if let Some(ports_array) = container["Ports"].as_array() {
|
||||
ports_array.iter().filter_map(|port| {
|
||||
@ -390,7 +426,8 @@ impl PodmanClient {
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
|
||||
let lan_address = Self::lan_address_for(&name);
|
||||
result.push(ContainerStatus {
|
||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||
name,
|
||||
@ -398,7 +435,7 @@ impl PodmanClient {
|
||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports,
|
||||
lan_address: None, // Set by docker_packages scanner
|
||||
lan_address,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 && \
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>Bitcoin Knots - Archipelago</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="tailwind.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@ -35,7 +35,7 @@
|
||||
.bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url('/assets/img/bg-network.jpg');
|
||||
background-image: url('assets/img/bg-network.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
@ -337,7 +337,7 @@
|
||||
<div class="flex-shrink-0">
|
||||
<div class="logo-gradient-border">
|
||||
<img
|
||||
src="/assets/img/app-icons/bitcoin-knots.webp"
|
||||
src="assets/img/app-icons/bitcoin-knots.webp"
|
||||
alt="Bitcoin Knots"
|
||||
class="w-16 h-16"
|
||||
style="object-fit: contain;"
|
||||
@ -591,7 +591,7 @@
|
||||
console.log('[Bitcoin UI] Script loaded, initializing...');
|
||||
|
||||
// RPC Configuration - Use local Nginx proxy within container
|
||||
const RPC_ENDPOINT = '/bitcoin-rpc/';
|
||||
const RPC_ENDPOINT = 'bitcoin-rpc/';
|
||||
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
|
||||
|
||||
// Make RPC call to Bitcoin node via local proxy
|
||||
|
||||
210
docker/bitcoin-ui/tailwind.css
Normal file
210
docker/bitcoin-ui/tailwind.css
Normal file
@ -0,0 +1,210 @@
|
||||
/* Tailwind CSS utilities — manually extracted for bitcoin-ui */
|
||||
/* Replaces cdn.tailwindcss.com to comply with CSP script-src 'self' */
|
||||
|
||||
*, ::before, ::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
img, svg { display: block; vertical-align: middle; max-width: 100%; height: auto; }
|
||||
button { cursor: pointer; background-color: transparent; font-family: inherit; font-size: 100%; line-height: inherit; color: inherit; margin: 0; padding: 0; }
|
||||
h1, h2 { font-size: inherit; font-weight: inherit; }
|
||||
|
||||
/* Position */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.fixed { position: fixed; }
|
||||
.inset-0 { inset: 0; }
|
||||
|
||||
/* Display */
|
||||
.hidden { display: none; }
|
||||
.flex { display: flex; }
|
||||
.grid { display: grid; }
|
||||
|
||||
/* Flex */
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-row { flex-direction: row; }
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
.flex-shrink-0 { flex-shrink: 0; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
|
||||
/* Gap */
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
/* Grid */
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
/* Width */
|
||||
.w-3 { width: 0.75rem; }
|
||||
.w-4 { width: 1rem; }
|
||||
.w-5 { width: 1.25rem; }
|
||||
.w-6 { width: 1.5rem; }
|
||||
.w-8 { width: 2rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
.w-16 { width: 4rem; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
/* Height */
|
||||
.h-1 { height: 0.25rem; }
|
||||
.h-2 { height: 0.5rem; }
|
||||
.h-3 { height: 0.75rem; }
|
||||
.h-4 { height: 1rem; }
|
||||
.h-5 { height: 1.25rem; }
|
||||
.h-6 { height: 1.5rem; }
|
||||
.h-8 { height: 2rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-16 { height: 4rem; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
/* Min/Max */
|
||||
.min-w-0 { min-width: 0px; }
|
||||
.max-w-2xl { max-width: 42rem; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
.max-h-\[80vh\] { max-height: 80vh; }
|
||||
|
||||
/* Padding */
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
|
||||
/* Margin */
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* Typography — Size */
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
|
||||
/* Typography — Weight */
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
.text-center { text-align: center; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Typography — Color */
|
||||
.text-white { color: #ffffff; }
|
||||
.text-white\/40 { color: rgba(255, 255, 255, 0.4); }
|
||||
.text-white\/50 { color: rgba(255, 255, 255, 0.5); }
|
||||
.text-white\/60 { color: rgba(255, 255, 255, 0.6); }
|
||||
.text-white\/70 { color: rgba(255, 255, 255, 0.7); }
|
||||
.text-white\/80 { color: rgba(255, 255, 255, 0.8); }
|
||||
.text-green-400 { color: #4ade80; }
|
||||
.text-green-500 { color: #22c55e; }
|
||||
.text-orange-400 { color: #fb923c; }
|
||||
.text-orange-500 { color: #f97316; }
|
||||
.text-red-400 { color: #f87171; }
|
||||
.text-yellow-400 { color: #facc15; }
|
||||
|
||||
/* Backgrounds */
|
||||
.bg-green-400 { background-color: #4ade80; }
|
||||
.bg-green-500 { background-color: #22c55e; }
|
||||
.bg-orange-400 { background-color: #fb923c; }
|
||||
.bg-orange-500 { background-color: #f97316; }
|
||||
.bg-red-400 { background-color: #f87171; }
|
||||
.bg-orange-500\/20 { background-color: rgba(249, 115, 22, 0.2); }
|
||||
.bg-white\/5 { background-color: rgba(255, 255, 255, 0.05); }
|
||||
.bg-white\/10 { background-color: rgba(255, 255, 255, 0.1); }
|
||||
.bg-black\/20 { background-color: rgba(0, 0, 0, 0.2); }
|
||||
.bg-black\/40 { background-color: rgba(0, 0, 0, 0.4); }
|
||||
.bg-black\/60 { background-color: rgba(0, 0, 0, 0.6); }
|
||||
.bg-black\/80 { background-color: rgba(0, 0, 0, 0.8); }
|
||||
|
||||
/* Gradients */
|
||||
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)); }
|
||||
.from-orange-500 { --tw-gradient-from: #f97316; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(249, 115, 22, 0)); }
|
||||
.from-orange-400 { --tw-gradient-from: #fb923c; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(251, 146, 60, 0)); }
|
||||
.to-yellow-400 { --tw-gradient-to: #facc15; }
|
||||
.to-orange-600 { --tw-gradient-to: #ea580c; }
|
||||
|
||||
/* Border */
|
||||
.border { border-width: 1px; }
|
||||
.border-white\/10 { border-color: rgba(255, 255, 255, 0.1); }
|
||||
.border-white\/20 { border-color: rgba(255, 255, 255, 0.2); }
|
||||
.border-orange-500\/30 { border-color: rgba(249, 115, 22, 0.3); }
|
||||
|
||||
/* Border Radius */
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-xl { border-radius: 0.75rem; }
|
||||
.rounded-2xl { border-radius: 1rem; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
|
||||
/* Overflow */
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
|
||||
/* Opacity */
|
||||
.opacity-0 { opacity: 0; }
|
||||
.opacity-75 { opacity: 0.75; }
|
||||
.opacity-100 { opacity: 1; }
|
||||
|
||||
/* Z-Index */
|
||||
.z-10 { z-index: 10; }
|
||||
.z-50 { z-index: 50; }
|
||||
|
||||
/* Spacing utilities */
|
||||
.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; }
|
||||
.space-y-3 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.75rem; }
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
|
||||
|
||||
/* Backdrop filter */
|
||||
.backdrop-blur-sm { -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||||
.backdrop-blur-md { -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
|
||||
.backdrop-blur-xl { -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); }
|
||||
|
||||
/* Transitions */
|
||||
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.duration-300 { transition-duration: 300ms; }
|
||||
.duration-500 { transition-duration: 500ms; }
|
||||
|
||||
/* Text wrapping */
|
||||
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||
.break-all { word-break: break-all; }
|
||||
|
||||
/* Cursor */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
|
||||
/* Responsive: md (768px+) */
|
||||
@media (min-width: 768px) {
|
||||
.md\:flex-row { flex-direction: row; }
|
||||
.md\:items-center { align-items: center; }
|
||||
.md\:gap-4 { gap: 1rem; }
|
||||
.md\:gap-6 { gap: 1.5rem; }
|
||||
.md\:w-auto { width: auto; }
|
||||
.md\:mt-0 { margin-top: 0; }
|
||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* Responsive: lg (1024px+) */
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
FROM docker.io/library/nginx:alpine
|
||||
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY qrcode.js /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN mkdir -p /usr/share/nginx/html/assets/img
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<title>Electrs - Archipelago</title>
|
||||
<title>ElectrumX - Archipelago</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; color: white; overflow-x: hidden; }
|
||||
@ -69,6 +69,28 @@
|
||||
.md-flex-row { flex-direction: row; }
|
||||
.md-grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
/* Connection details */
|
||||
.conn-tabs { display: flex; background: rgba(255,255,255,0.08); border-radius: 0.5rem; overflow: hidden; margin-bottom: 1.5rem; }
|
||||
.conn-tab { flex: 1; padding: 0.625rem 1rem; text-align: center; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; color: rgba(255,255,255,0.5); border: none; background: none; }
|
||||
.conn-tab.active { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
|
||||
.conn-tab:hover:not(.active) { color: rgba(255,255,255,0.8); }
|
||||
.conn-layout { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
@media (min-width: 640px) { .conn-layout { flex-direction: row; } }
|
||||
.qr-box { flex-shrink: 0; width: 196px; background: white; border-radius: 0.75rem; padding: 0.75rem; display: flex; align-items: center; justify-content: center; }
|
||||
.qr-box img { width: 100%; height: auto; display: block; image-rendering: pixelated; }
|
||||
.conn-fields { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.45); margin-bottom: 0.25rem; }
|
||||
.field-row { display: flex; align-items: center; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; overflow: hidden; }
|
||||
.field-value { flex: 1; padding: 0.625rem 0.875rem; font-family: monospace; font-size: 0.9375rem; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.copy-btn { padding: 0.625rem 0.75rem; background: none; border: none; border-left: 1px solid rgba(255,255,255,0.1); cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.2s ease; display: flex; align-items: center; }
|
||||
.copy-btn:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
.copy-btn.copied { color: #4ade80; }
|
||||
.field-row-split { display: flex; gap: 0.75rem; }
|
||||
.field-row-split > div { flex: 1; }
|
||||
.conn-disabled { text-align: center; padding: 2rem 1rem; }
|
||||
.help-text { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(255,255,255,0.08); font-size: 0.875rem; color: rgba(255,255,255,0.5); line-height: 1.5; }
|
||||
.version-text { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -76,6 +98,7 @@
|
||||
<div class="overlay"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex flex-col md-flex-row items-center gap-4">
|
||||
<div class="icon-box flex-shrink-0">
|
||||
@ -84,8 +107,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-white">Electrs</h1>
|
||||
<p class="text-white-70">Bitcoin Electrum indexer for Mempool & Electrum clients</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-white">ElectrumX</h1>
|
||||
<span class="version-text">v1.18.0</span>
|
||||
</div>
|
||||
<p class="text-white-70">Bitcoin Electrum server for wallet connections</p>
|
||||
</div>
|
||||
<div class="info-card flex items-center gap-3">
|
||||
<div id="statusDot" class="status-dot bg-yellow"></div>
|
||||
@ -97,7 +123,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<!-- Sync Status -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="icon-box-sm flex-shrink-0">
|
||||
<svg id="syncIcon" style="width:1.5rem;height:1.5rem;color:#f97316" class="animate-spin-slow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -139,17 +166,215 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Details -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Connect Your Wallet</h2>
|
||||
<p class="text-white-70 text-sm mb-4" id="connSubtitle">Use the following details to connect your wallet or application to ElectrumX.</p>
|
||||
|
||||
<div class="conn-tabs">
|
||||
<button class="conn-tab active" id="tabLocal" onclick="switchTab('local')">Local Network</button>
|
||||
<button class="conn-tab" id="tabTor" onclick="switchTab('tor')">Tor</button>
|
||||
</div>
|
||||
|
||||
<!-- Local Network Tab -->
|
||||
<div id="panelLocal" class="conn-layout">
|
||||
<div class="qr-box" id="qrLocalBox"></div>
|
||||
<div class="conn-fields">
|
||||
<div>
|
||||
<div class="field-label">Address</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value" id="localAddress">-</span>
|
||||
<button class="copy-btn" onclick="copyField('localAddress', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row-split">
|
||||
<div>
|
||||
<div class="field-label">Port</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">50001</span>
|
||||
<button class="copy-btn" onclick="copyText('50001', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">SSL</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">Disabled</span>
|
||||
<button class="copy-btn" onclick="copyText('Disabled', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tor Tab -->
|
||||
<div id="panelTor" style="display:none">
|
||||
<div id="torAvailable" class="conn-layout" style="display:none">
|
||||
<div class="qr-box" id="qrTorBox"></div>
|
||||
<div class="conn-fields">
|
||||
<div>
|
||||
<div class="field-label">Onion Address</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value" id="torAddress" style="font-size:0.75rem">-</span>
|
||||
<button class="copy-btn" onclick="copyField('torAddress', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row-split">
|
||||
<div>
|
||||
<div class="field-label">Port</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">50001</span>
|
||||
<button class="copy-btn" onclick="copyText('50001', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">SSL</div>
|
||||
<div class="field-row">
|
||||
<span class="field-value">Disabled</span>
|
||||
<button class="copy-btn" onclick="copyText('Disabled', this)" title="Copy">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="torUnavailable" class="conn-disabled">
|
||||
<svg style="width:2.5rem;height:2.5rem;color:rgba(255,255,255,0.25);margin:0 auto 0.75rem" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<p class="text-white-70" style="font-size:0.9375rem">Tor hidden service not configured for ElectrumX.</p>
|
||||
<p class="text-white-60 text-sm" style="margin-top:0.375rem">Enable Tor for ElectrumX in Settings to connect remotely.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-text">
|
||||
Connect using <strong style="color:rgba(255,255,255,0.8)">Sparrow Wallet</strong>, <strong style="color:rgba(255,255,255,0.8)">Electrum</strong>, or any Electrum-protocol compatible wallet. Set the server to the address and port shown above with SSL disabled.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="qrcode.js"></script>
|
||||
<script>
|
||||
var currentTab = 'local';
|
||||
var torOnion = null;
|
||||
|
||||
function renderQR(containerId, text) {
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
try {
|
||||
var qr = qrcode(0, 'M');
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
container.innerHTML = qr.createImgTag(5, 0);
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:#999;font-size:12px;text-align:center;padding:2rem">QR unavailable</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.getElementById('tabLocal').classList.toggle('active', tab === 'local');
|
||||
document.getElementById('tabTor').classList.toggle('active', tab === 'tor');
|
||||
document.getElementById('panelLocal').style.display = tab === 'local' ? '' : 'none';
|
||||
document.getElementById('panelTor').style.display = tab === 'tor' ? '' : 'none';
|
||||
}
|
||||
|
||||
var COPY_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>';
|
||||
var CHECK_SVG = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
|
||||
|
||||
function flashCopied(btn) {
|
||||
btn.classList.add('copied');
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = CHECK_SVG;
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = orig;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function copyField(id, btn) {
|
||||
var text = document.getElementById(id).textContent.trim();
|
||||
if (!text || text === '-') return;
|
||||
navigator.clipboard.writeText(text).then(function() { flashCopied(btn); });
|
||||
}
|
||||
|
||||
function copyText(text, btn) {
|
||||
navigator.clipboard.writeText(text).then(function() { flashCopied(btn); });
|
||||
}
|
||||
|
||||
function copyConnStr(type) {
|
||||
var id = type === 'tor' ? 'torConnStr' : 'localConnStr';
|
||||
var btn = type === 'tor' ? document.querySelector('#torAvailable .conn-string-copy') : document.getElementById('localCopyAll');
|
||||
var text = document.getElementById(id).textContent.trim();
|
||||
if (!text || text === '-') return;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.classList.add('copied');
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = orig;
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectionInfo() {
|
||||
var host = window.location.hostname;
|
||||
document.getElementById('localAddress').textContent = host;
|
||||
renderQR('qrLocalBox', host + ':50001:t');
|
||||
|
||||
if (torOnion) {
|
||||
document.getElementById('torAvailable').style.display = '';
|
||||
document.getElementById('torUnavailable').style.display = 'none';
|
||||
document.getElementById('torAddress').textContent = torOnion;
|
||||
renderQR('qrTorBox', torOnion + ':50001:t');
|
||||
} else {
|
||||
document.getElementById('torAvailable').style.display = 'none';
|
||||
document.getElementById('torUnavailable').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTorInfo() {
|
||||
try {
|
||||
var resp = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ method: 'tor.list-services', params: {} })
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.result && data.result.services) {
|
||||
var svc = data.result.services.find(function(s) {
|
||||
return s.name === 'electrumx' || s.name === 'electrs' || s.local_port === 50001;
|
||||
});
|
||||
if (svc && svc.onion_address) {
|
||||
torOnion = svc.onion_address;
|
||||
updateConnectionInfo();
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const resp = await fetch('electrs-status');
|
||||
const data = await resp.json();
|
||||
var resp = await fetch('electrs-status');
|
||||
var data = await resp.json();
|
||||
|
||||
const indexedH = data.indexed_height ?? 0;
|
||||
const networkH = data.network_height ?? 0;
|
||||
const pct = data.progress_pct ?? 0;
|
||||
var indexedH = data.indexed_height || 0;
|
||||
var networkH = data.network_height || 0;
|
||||
var pct = data.progress_pct || 0;
|
||||
|
||||
document.getElementById('indexedHeight').textContent = indexedH > 0 ? indexedH.toLocaleString() : (data.status === 'indexing' ? 'Building...' : '-');
|
||||
document.getElementById('networkHeight').textContent = networkH > 0 ? networkH.toLocaleString() : '-';
|
||||
@ -159,35 +384,39 @@
|
||||
document.getElementById('syncPercentage').textContent = pct > 0 ? pct.toFixed(1) + '%' : '0%';
|
||||
document.getElementById('syncProgressBar').style.width = Math.max(pct, 0.5) + '%';
|
||||
|
||||
const statusText = document.getElementById('syncStatusText');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const syncIcon = document.getElementById('syncIcon');
|
||||
var statusTextEl = document.getElementById('syncStatusText');
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var syncIcon = document.getElementById('syncIcon');
|
||||
|
||||
if (data.status === 'indexing') {
|
||||
statusText.textContent = data.error || 'Building index...';
|
||||
statusText.style.color = '#fbbf24';
|
||||
statusTextEl.textContent = data.error || 'Building index...';
|
||||
statusTextEl.style.color = '#fbbf24';
|
||||
statusDot.className = 'status-dot bg-amber animate-pulse';
|
||||
document.getElementById('statusText').textContent = 'Indexing';
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
|
||||
} else if (data.status === 'error') {
|
||||
statusText.textContent = data.error || 'Unknown error';
|
||||
statusText.style.color = '#f87171';
|
||||
statusTextEl.textContent = data.error || 'Unknown error';
|
||||
statusTextEl.style.color = '#f87171';
|
||||
statusDot.className = 'status-dot bg-red';
|
||||
document.getElementById('statusText').textContent = 'Error';
|
||||
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
|
||||
} else if (data.status === 'synced') {
|
||||
statusText.textContent = 'Fully synchronized with the network';
|
||||
statusText.style.color = '#4ade80';
|
||||
statusTextEl.textContent = 'Fully synchronized with the network';
|
||||
statusTextEl.style.color = '#4ade80';
|
||||
statusDot.className = 'status-dot bg-green';
|
||||
document.getElementById('statusText').textContent = 'Synced';
|
||||
syncIcon.classList.remove('animate-spin-slow');
|
||||
syncIcon.style.color = '#4ade80';
|
||||
document.getElementById('connSubtitle').textContent = 'Use the following details to connect your wallet or application to ElectrumX.';
|
||||
} else {
|
||||
const remaining = networkH - indexedH;
|
||||
statusText.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
|
||||
statusText.style.color = '#fb923c';
|
||||
var remaining = networkH - indexedH;
|
||||
statusTextEl.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
|
||||
statusTextEl.style.color = '#fb923c';
|
||||
statusDot.className = 'status-dot bg-yellow';
|
||||
document.getElementById('statusText').textContent = 'Syncing';
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('syncStatusText').textContent = 'Unable to fetch status: ' + e.message;
|
||||
@ -196,6 +425,8 @@
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
updateConnectionInfo();
|
||||
fetchTorInfo();
|
||||
setInterval(updateStatus, 5000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
2297
docker/electrs-ui/qrcode.js
Normal file
2297
docker/electrs-ui/qrcode.js
Normal file
File diff suppressed because it is too large
Load Diff
71
docs/MASTER_PLAN.md
Normal file
71
docs/MASTER_PLAN.md
Normal file
@ -0,0 +1,71 @@
|
||||
# MASTER PLAN
|
||||
|
||||
> Archipelago project task tracking and roadmap.
|
||||
|
||||
## Roadmap
|
||||
|
||||
| ID | Title | Priority | Status | Dependencies |
|
||||
|----|-------|----------|--------|--------------|
|
||||
| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - |
|
||||
| **TASK-2** | **Roll incoming-tx into deploy & ISO** | **P2** | PLANNED | - |
|
||||
| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - |
|
||||
|
||||
## Active Work
|
||||
|
||||
### BUG-1: Random logout / CSRF mismatch (PLANNED)
|
||||
**Priority**: P0 — Critical
|
||||
**Status**: PLANNED (2026-03-15)
|
||||
|
||||
Sessions expire unexpectedly during normal use. Backend sessions now persist to disk (`/var/lib/archipelago/sessions.json`) but CSRF token mismatch (403) still causes logouts. Need to investigate CSRF token lifecycle and fix the mismatch between cookie and header values.
|
||||
|
||||
**Root cause analysis so far**:
|
||||
- Sessions were purely in-memory — fixed with disk persistence
|
||||
- CSRF validation compares cookie value vs `X-CSRF-Token` header — both present but don't match
|
||||
- Log: `403 CSRF mismatch — rejecting RPC call ... has_cookie=true has_header=true`
|
||||
- Possible cause: cookie value rotated (e.g., new login in another tab) but frontend cached old value
|
||||
|
||||
**Key files**:
|
||||
- `core/archipelago/src/session.rs` — session store (now persisted)
|
||||
- `core/archipelago/src/api/rpc/mod.rs:273-307` — CSRF validation
|
||||
- `neode-ui/src/api/rpc-client.ts:18-45` — frontend CSRF extraction from cookie
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Investigate CSRF token rotation — when/why cookie and header diverge
|
||||
- [ ] Add logging to CSRF validation to capture actual cookie vs header values
|
||||
- [ ] Consider returning CSRF token in response body (not just cookie) for explicit client storage
|
||||
- [ ] Test multi-tab scenario where one tab's login rotates the CSRF token
|
||||
- [ ] Verify session persistence survives deploys (second deploy test)
|
||||
|
||||
### TASK-2: Roll incoming-tx into deploy & ISO (PLANNED)
|
||||
**Priority**: P2 — Medium
|
||||
**Status**: PLANNED (2026-03-16)
|
||||
|
||||
The incoming transactions feature (lnd.gettransactions RPC + wallet badge UI + auto-refresh) is working on .228. Roll changes into deploy-to-target.sh and build-auto-installer-iso.sh so fresh installs and deploys get it automatically. Do not break existing changes.
|
||||
|
||||
**Key files changed**:
|
||||
- `core/archipelago/src/api/rpc/lnd.rs` — new `handle_lnd_gettransactions` method
|
||||
- `core/archipelago/src/api/rpc/mod.rs` — registered `lnd.gettransactions` route
|
||||
- `neode-ui/src/views/Web5.vue` — incoming tx badge, panel, auto-refresh polling
|
||||
- `neode-ui/src/style.css` — incoming-tx-badge, incoming-tx-row, incoming-tx-slide classes
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Verify changes are already captured by existing deploy (backend build + frontend build)
|
||||
- [ ] Ensure ISO build captures the updated Rust binary and frontend dist
|
||||
- [ ] Test that no existing deploy/build logic is broken
|
||||
|
||||
### BUG-3: IndeedHub WebSocket spam in console (PLANNED)
|
||||
**Priority**: P2 — Medium
|
||||
**Status**: PLANNED (2026-03-16)
|
||||
|
||||
`ws://localhost:7777/` connection refused fills browser console endlessly when IndeedHub is loaded in iframe. IndeedHub's compiled frontend bundle hardcodes `localhost` for WebSocket connections. When loaded from a remote host, `localhost` resolves to the user's machine, not the server.
|
||||
|
||||
**Root cause**: IndeedHub's Next.js build bakes `localhost:7777` into the WebSocket URL. The nginx WebSocket proxy at `/app/indeedhub/ws/` exists but is unused because IndeedHub loads via direct port 7777, not through the proxy path.
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Rebuild IndeedHub with `NEXT_PUBLIC_WS_URL` env var pointing to relative URL or actual server address
|
||||
- [ ] Alternatively, configure IndeedHub to use relative WebSocket URLs (`/ws/` instead of `ws://localhost:7777/`)
|
||||
- [ ] Test that WebSocket reconnection works after the fix
|
||||
|
||||
## Completed
|
||||
|
||||
<!-- Done tasks are moved here -->
|
||||
@ -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
|
||||
|
||||
@ -11,7 +11,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always;
|
||||
|
||||
# AIUI SPA (Chat mode iframe)
|
||||
# Use =404 fallback instead of index.html to prevent serving HTML with wrong
|
||||
@ -487,7 +487,7 @@ server {
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/electrs/ {
|
||||
location /app/electrumx/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@ -675,7 +675,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:*; frame-src *" always;
|
||||
|
||||
# AIUI SPA (Chat mode iframe)
|
||||
location /aiui/ {
|
||||
|
||||
@ -218,7 +218,7 @@ location /app/bitcoin-ui/ {
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/electrs/ {
|
||||
location /app/electrumx/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@ -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"), {
|
||||
|
||||
511
neode-ui/package-lock.json
generated
511
neode-ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
neode-ui/public/assets/img/app-icons/electrumx.webp
Normal file
BIN
neode-ui/public/assets/img/app-icons/electrumx.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@ -1,79 +1,160 @@
|
||||
/**
|
||||
* NIP-07 Nostr Provider Shim
|
||||
* NIP-07 Nostr Provider Shim — Archipelago
|
||||
*
|
||||
* Injected into proxied iframe apps via nginx sub_filter.
|
||||
* Implements window.nostr interface (getPublicKey, signEvent)
|
||||
* by communicating with the parent Archipelago frame via postMessage.
|
||||
*
|
||||
* Security: validates postMessage origin, never exposes secret key.
|
||||
* Provides window.nostr (NIP-07) for iframe apps.
|
||||
* Auto sign-in: does NIP-98 auth directly then reloads so the app
|
||||
* picks up the valid session. Shows a loading overlay during auth.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Only inject if we're inside an iframe
|
||||
if (window.__archipelagoNostr) return;
|
||||
window.__archipelagoNostr = true;
|
||||
if (window === window.top) return;
|
||||
|
||||
// Don't override existing NIP-07 extensions
|
||||
if (window.nostr) return;
|
||||
|
||||
var pending = {};
|
||||
var nextId = 1;
|
||||
var pending = {}, nextId = 1;
|
||||
|
||||
function request(method, params) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var id = nextId++;
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
window.parent.postMessage(
|
||||
{ type: 'nostr-request', id: id, method: method, params: params || {} },
|
||||
'*'
|
||||
);
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(function () {
|
||||
if (pending[id]) {
|
||||
pending[id].reject(new Error('NIP-07 request timed out'));
|
||||
delete pending[id];
|
||||
}
|
||||
}, 30000);
|
||||
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
|
||||
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (!event.data || event.data.type !== 'nostr-response') return;
|
||||
var handler = pending[event.data.id];
|
||||
if (!handler) return;
|
||||
delete pending[event.data.id];
|
||||
if (event.data.error) {
|
||||
handler.reject(new Error(event.data.error));
|
||||
} else {
|
||||
handler.resolve(event.data.result);
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'nostr-response') return;
|
||||
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
|
||||
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
|
||||
});
|
||||
|
||||
window.nostr = {
|
||||
getPublicKey: function () {
|
||||
return request('getPublicKey');
|
||||
},
|
||||
signEvent: function (event) {
|
||||
return request('signEvent', { event: event });
|
||||
},
|
||||
getRelays: function () {
|
||||
return request('getRelays');
|
||||
},
|
||||
getPublicKey: function () { return request('getPublicKey'); },
|
||||
signEvent: function (ev) { return request('signEvent', { event: ev }); },
|
||||
sign: function (ev) { return request('signEvent', { event: ev }); },
|
||||
getRelays: function () { return request('getRelays'); },
|
||||
nip04: {
|
||||
encrypt: function (pubkey, plaintext) {
|
||||
return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext });
|
||||
},
|
||||
decrypt: function (pubkey, ciphertext) {
|
||||
return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
|
||||
},
|
||||
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
nip44: {
|
||||
encrypt: function (pubkey, plaintext) {
|
||||
return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext });
|
||||
},
|
||||
decrypt: function (pubkey, ciphertext) {
|
||||
return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
|
||||
},
|
||||
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
};
|
||||
|
||||
// --- Loading Overlay ---
|
||||
var overlay = null;
|
||||
|
||||
function showLoader(message) {
|
||||
if (overlay) return;
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'archipelago-auth-overlay';
|
||||
overlay.innerHTML =
|
||||
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
|
||||
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
|
||||
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
|
||||
'</svg>' +
|
||||
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
|
||||
'</div>';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function updateLoader(message) {
|
||||
if (!overlay) return;
|
||||
var txt = overlay.querySelector('div > div');
|
||||
if (txt) txt.textContent = message;
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
if (overlay) { overlay.remove(); overlay = null; }
|
||||
}
|
||||
|
||||
// --- Direct NIP-98 Auth ---
|
||||
var authDone = false;
|
||||
|
||||
function doNip98Auth(pubkey) {
|
||||
if (authDone) return;
|
||||
authDone = true;
|
||||
|
||||
var apiBase = '/api';
|
||||
var healthUrl = window.location.origin + apiBase + '/nostr-auth/health';
|
||||
var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session';
|
||||
|
||||
// 1. Check if API backend is reachable (3s timeout)
|
||||
var hc = new AbortController();
|
||||
var ht = setTimeout(function () { hc.abort(); }, 3000);
|
||||
|
||||
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
|
||||
clearTimeout(ht);
|
||||
if (!r.ok) throw new Error('Health ' + r.status);
|
||||
|
||||
// 2. API is up — show loader and do NIP-98
|
||||
showLoader('Signing in with Nostr...');
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var event = {
|
||||
kind: 27235, created_at: now, content: '', pubkey: pubkey,
|
||||
tags: [['u', sessionUrl], ['method', 'POST']]
|
||||
};
|
||||
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
|
||||
return window.nostr.signEvent(event);
|
||||
|
||||
}).then(function (signed) {
|
||||
updateLoader('Creating session...');
|
||||
var ac = new AbortController();
|
||||
setTimeout(function () { ac.abort(); }, 10000);
|
||||
return fetch(sessionUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
|
||||
signal: ac.signal
|
||||
});
|
||||
|
||||
}).then(function (res) {
|
||||
console.log('[nostr-provider] NIP-98: response', res.status);
|
||||
if (!res.ok) throw new Error('Auth failed: ' + res.status);
|
||||
return res.json();
|
||||
|
||||
}).then(function (data) {
|
||||
if (data.accessToken) {
|
||||
sessionStorage.setItem('nostr_token', data.accessToken);
|
||||
sessionStorage.setItem('nostr_pubkey', pubkey);
|
||||
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
||||
updateLoader('Signed in! Loading...');
|
||||
console.log('[nostr-provider] NIP-98: success, reloading...');
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
} else {
|
||||
hideLoader(); authDone = false;
|
||||
}
|
||||
|
||||
}).catch(function (err) {
|
||||
hideLoader(); authDone = false;
|
||||
var msg = err.message || String(err);
|
||||
if (msg.indexOf('abort') > -1) msg = 'API timeout';
|
||||
console.warn('[nostr-provider] NIP-98 skipped:', msg);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for identity from parent Archipelago frame
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'archipelago:identity') return;
|
||||
var pk = e.data.nostr_pubkey;
|
||||
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
|
||||
if (!pk) return;
|
||||
|
||||
// Skip if already signed in with a real token (not mock)
|
||||
try {
|
||||
var token = sessionStorage.getItem('nostr_token');
|
||||
if (token && token.indexOf('mock-') === -1) {
|
||||
console.log('[nostr-provider] Already signed in with real token');
|
||||
return;
|
||||
}
|
||||
} catch (x) {}
|
||||
|
||||
setTimeout(function () { doNip98Auth(pk); }, 1500);
|
||||
});
|
||||
})();
|
||||
|
||||
@ -6,13 +6,16 @@
|
||||
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<!-- Backdrop — near-black -->
|
||||
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
|
||||
<!-- Backdrop — frosted blur -->
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
|
||||
|
||||
<!-- Main panel -->
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`Select identity for ${appName}`"
|
||||
class="relative z-10 w-full max-w-lg"
|
||||
>
|
||||
<!-- Header: screensaver-style glass disc + radial viz ring -->
|
||||
@ -43,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Identity list -->
|
||||
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
@ -61,15 +64,18 @@
|
||||
v-for="identity in identities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
|
||||
role="radio"
|
||||
:aria-checked="selectedId === identity.id"
|
||||
:aria-label="`Identity: ${identity.name}`"
|
||||
class="w-full text-left p-3 rounded-lg transition-all duration-200"
|
||||
:class="selectedId === identity.id
|
||||
? 'bg-white/8 border-white/25'
|
||||
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
|
||||
? 'bg-white/10 ring-1 ring-white/20'
|
||||
: 'bg-white/[0.03] hover:bg-white/[0.06]'"
|
||||
@click="selectedId = identity.id"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
:class="avatarClasses(identity.purpose)"
|
||||
>
|
||||
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
@ -85,10 +91,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/15 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/70"></div>
|
||||
</div>
|
||||
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -104,8 +110,8 @@
|
||||
:disabled="!selectedId || !hasNostrKey"
|
||||
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:class="selectedId && hasNostrKey
|
||||
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
|
||||
: 'bg-white/3 border border-white/8 text-white/40'"
|
||||
? 'bg-white/10 text-white hover:bg-white/15'
|
||||
: 'bg-white/[0.03] text-white/40'"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
@ -193,9 +199,9 @@ function truncateNpub(npub: string): string {
|
||||
|
||||
function avatarClasses(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25'
|
||||
default: return 'bg-white/10 text-white/80 border-white/20'
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400'
|
||||
default: return 'bg-white/10 text-white/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -3,97 +3,59 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
|
||||
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
|
||||
* These always open in a new tab. Other external sites load directly in the iframe. */
|
||||
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
|
||||
const IFRAME_BLOCKED_HOSTS: string[] = []
|
||||
|
||||
/** External site proxy paths — disabled. External URLs load directly in the iframe
|
||||
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
|
||||
const EXTERNAL_PROXY_PATH: Record<string, string> = {}
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
'23000', // BTCPay — X-Frame-Options: DENY
|
||||
'3000', // Grafana — X-Frame-Options: deny
|
||||
'2342', // PhotoPrism — X-Frame-Options: DENY
|
||||
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
||||
])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// External sites that block iframes
|
||||
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
|
||||
return true
|
||||
}
|
||||
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
|
||||
if (
|
||||
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
|
||||
u.port === '3000' || // Grafana — X-Frame-Options: deny
|
||||
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
|
||||
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
|
||||
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return NEW_TAB_PORTS.has(u.port)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
|
||||
const PORT_TO_PROXY: Record<string, string> = {
|
||||
'81': '/app/nginx-proxy-manager/',
|
||||
'3000': '/app/grafana/',
|
||||
'3001': '/app/uptime-kuma/',
|
||||
'8080': '/app/endurain/',
|
||||
'8081': '/app/lnd/',
|
||||
'8082': '/app/vaultwarden/',
|
||||
'8083': '/app/filebrowser/',
|
||||
'8085': '/app/nextcloud/',
|
||||
'8096': '/app/jellyfin/',
|
||||
'8123': '/app/homeassistant/',
|
||||
'8240': '/app/tailscale/',
|
||||
'8334': '/app/bitcoin-ui/',
|
||||
'8888': '/app/searxng/',
|
||||
'9000': '/app/portainer/',
|
||||
'9001': '/app/penpot/',
|
||||
'9980': '/app/onlyoffice/',
|
||||
'11434': '/app/ollama/',
|
||||
'2283': '/app/immich/',
|
||||
'23000': '/app/btcpay/',
|
||||
'2342': '/app/photoprism/',
|
||||
'4080': '/app/mempool/',
|
||||
'8175': '/app/fedimint/',
|
||||
'8176': '/app/fedimint-gateway/',
|
||||
'3100': '/app/dwn/',
|
||||
'18081': '/app/nostr-rs-relay/',
|
||||
'7777': '/app/indeedhub/',
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3001': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
'8096': 'jellyfin',
|
||||
'8123': 'homeassistant',
|
||||
'8240': 'tailscale',
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9001': 'penpot',
|
||||
'9980': 'onlyoffice',
|
||||
'11434': 'ollama',
|
||||
'2283': 'immich',
|
||||
'23000': 'btcpay-server',
|
||||
'2342': 'photoprism',
|
||||
'4080': 'mempool',
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'3100': 'dwn',
|
||||
'18081': 'nostr-rs-relay',
|
||||
'7777': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
|
||||
* On HTTP, direct port URLs are used — they avoid subpath routing issues
|
||||
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
|
||||
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
|
||||
*/
|
||||
function toEmbeddableUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const origin = window.location.origin
|
||||
|
||||
// External sites proxied through nginx path-based locations
|
||||
const extPath = EXTERNAL_PROXY_PATH[u.hostname]
|
||||
if (extPath) {
|
||||
return `${origin}${extPath}`
|
||||
}
|
||||
|
||||
const proxyPath = PORT_TO_PROXY[u.port]
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
||||
if (proxyPath && sameHost && needsProxy) {
|
||||
return `${origin}${proxyPath}`
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
@ -121,6 +83,8 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
@ -129,9 +93,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Open app in full-page session view (preferred — no iframe subpath issues) */
|
||||
/** Active app in panel mode (store-based, no route change) */
|
||||
const panelAppId = ref<string | null>(null)
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function openSession(appId: string) {
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel') {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelAppId.value = null
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
@ -142,13 +119,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
openSession(resolvedId)
|
||||
return
|
||||
}
|
||||
// Apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = embeddableUrl
|
||||
url.value = payload.url
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
@ -158,11 +135,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check port-based apps
|
||||
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
|
||||
if (u.port === port) {
|
||||
return proxyPath.replace('/app/', '').replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
// Check external URLs
|
||||
const EXTERNAL_APP_HOSTS: Record<string, string> = {
|
||||
'botfights.net': 'botfights',
|
||||
@ -326,6 +300,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
open,
|
||||
openSession,
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
approveConsent,
|
||||
|
||||
@ -16,6 +16,17 @@ export interface BundledApp {
|
||||
lan_address?: string // Runtime launch URL from backend
|
||||
}
|
||||
|
||||
/** Map bundled app ID to the podman container name(s) used for status matching.
|
||||
* Some apps have a different container name than their app ID, or use a
|
||||
* separate UI container (e.g., bitcoin-knots node → bitcoin-ui web container). */
|
||||
const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
export const BUNDLED_APPS: BundledApp[] = [
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
@ -23,7 +34,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'localhost/bitcoinknots/bitcoin:29',
|
||||
description: 'Full Bitcoin node with additional features',
|
||||
icon: '₿',
|
||||
ports: [{ host: 8332, container: 8332 }, { host: 8333, container: 8333 }],
|
||||
ports: [{ host: 8334, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/bitcoin', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
@ -33,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 9735, container: 9735 }, { host: 10009, container: 10009 }],
|
||||
ports: [{ host: 8081, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
@ -48,12 +59,12 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
category: 'home',
|
||||
},
|
||||
{
|
||||
id: 'btcpayserver',
|
||||
id: 'btcpay-server',
|
||||
name: 'BTCPay Server',
|
||||
image: 'docker.io/btcpayserver/btcpayserver:latest',
|
||||
description: 'Self-hosted Bitcoin payment processor',
|
||||
icon: '💳',
|
||||
ports: [{ host: 23000, container: 23000 }],
|
||||
ports: [{ host: 23000, container: 49392 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/btcpay', container: '/datadir' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
@ -63,30 +74,10 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/mempool/frontend:latest',
|
||||
description: 'Bitcoin blockchain and mempool visualizer',
|
||||
icon: '🔍',
|
||||
ports: [{ host: 8080, container: 8080 }],
|
||||
ports: [{ host: 4080, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/mempool', container: '/data' }],
|
||||
category: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'nostr-rs-relay',
|
||||
name: 'Nostr Relay (RS)',
|
||||
image: 'docker.io/scsibug/nostr-rs-relay:latest',
|
||||
description: 'Rust-based Nostr relay for decentralized social',
|
||||
icon: '🦩',
|
||||
ports: [{ host: 8008, container: 8080 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/nostr-rs', container: '/usr/src/app/db' }],
|
||||
category: 'other',
|
||||
},
|
||||
{
|
||||
id: 'strfry',
|
||||
name: 'Strfry Relay',
|
||||
image: 'docker.io/hoytech/strfry:latest',
|
||||
description: 'High-performance Nostr relay',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 7777, container: 7777 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/strfry', container: '/app/strfry-db' }],
|
||||
category: 'other',
|
||||
},
|
||||
{
|
||||
id: 'tailscale',
|
||||
name: 'Tailscale VPN',
|
||||
@ -124,14 +115,18 @@ export const useContainerStore = defineStore('container', () => {
|
||||
healthStatus.value[appId] || 'unknown'
|
||||
)
|
||||
|
||||
// Get container for a bundled app (matches by name)
|
||||
// Get container for a bundled app (matches by explicit name map, then by exact name)
|
||||
const getContainerForApp = computed(() => (appId: string) => {
|
||||
return containers.value.find(c =>
|
||||
c.name === appId ||
|
||||
c.name.includes(appId) ||
|
||||
c.name === `archipelago-${appId}` ||
|
||||
c.name === `archipelago-${appId}-dev`
|
||||
)
|
||||
const nameList = CONTAINER_NAME_MAP[appId]
|
||||
if (nameList) {
|
||||
// Try each known container name in priority order
|
||||
for (const n of nameList) {
|
||||
const found = containers.value.find(c => c.name === n)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
// Fallback: exact match on app ID
|
||||
return containers.value.find(c => c.name === appId)
|
||||
})
|
||||
|
||||
// Check if an app is currently loading (starting/stopping)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -526,8 +526,9 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
indeedhub: 'indeedhub',
|
||||
electrs: 'mempool-electrs',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrumx: 'electrumx',
|
||||
electrs: 'electrumx',
|
||||
'mempool-electrs': 'electrumx',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
@ -536,7 +537,7 @@ const PACKAGE_ALIASES: Record<string, string[]> = {
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'mempool-web': ['archy-mempool-web'],
|
||||
indeedhub: ['indeedhub-build_app_1'],
|
||||
electrs: ['mempool-electrs', 'archy-electrs'],
|
||||
electrumx: ['mempool-electrs', 'electrs', 'archy-electrs'],
|
||||
}
|
||||
|
||||
function resolvePackageKey(routeId: string): string {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="app-session-root">
|
||||
<Teleport to="body" :disabled="displayMode === 'panel'">
|
||||
<Teleport to="body" :disabled="isInlinePanel">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
@click.self="goBack"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
ref="sessionRef"
|
||||
@ -178,12 +178,24 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
|
||||
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
/** True when rendered inline via store (panel mode), false when route-based */
|
||||
const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@ -214,6 +226,25 @@ function setMode(mode: DisplayMode) {
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
showModeMenu.value = false
|
||||
|
||||
// Switch from inline panel → route-based overlay/fullscreen
|
||||
if (isInlinePanel.value && mode !== 'panel') {
|
||||
const id = appId.value
|
||||
emit('close')
|
||||
router.push({ name: 'app-session', params: { appId: id } })
|
||||
return
|
||||
}
|
||||
|
||||
// Switch from route-based → inline panel
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
router.push({ name: 'apps' }).then(() => {
|
||||
launcher.panelAppId = id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enter fullscreen if selected
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
@ -222,49 +253,92 @@ function setMode(mode: DisplayMode) {
|
||||
|
||||
// Reactive classes based on display mode
|
||||
const backdropClasses = computed(() => {
|
||||
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
|
||||
return 'app-session-backdrop-overlay'
|
||||
}
|
||||
return 'app-session-backdrop-panel'
|
||||
if (isInlinePanel.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
})
|
||||
|
||||
const panelClasses = computed(() => {
|
||||
const base = 'app-session-panel glass-card'
|
||||
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
|
||||
if (isInlinePanel.value) return `${base} app-session-inline`
|
||||
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
|
||||
return `${base} app-session-inpanel`
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
const appId = computed(() => route.params.appId as string)
|
||||
const appId = computed(() => props.appIdProp || (route.params.appId as string))
|
||||
|
||||
const APP_URLS: Record<string, string> = {
|
||||
// Container apps — use nginx proxy paths (strips X-Frame-Options)
|
||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
||||
const APP_PORTS: Record<string, number> = {
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'lnd': 8081,
|
||||
'archy-lnd-ui': 8081,
|
||||
'mempool': 4080,
|
||||
'mempool-web': 4080,
|
||||
'archy-mempool-web': 4080,
|
||||
'homeassistant': 8123,
|
||||
'grafana': 3000,
|
||||
'searxng': 8888,
|
||||
'ollama': 11434,
|
||||
'onlyoffice': 9980,
|
||||
'penpot': 9001,
|
||||
'nextcloud': 8085,
|
||||
'vaultwarden': 8082,
|
||||
'jellyfin': 8096,
|
||||
'photoprism': 2342,
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'filebrowser': 8083,
|
||||
'nginx-proxy-manager': 81,
|
||||
'portainer': 9000,
|
||||
'uptime-kuma': 3001,
|
||||
'tailscale': 8240,
|
||||
'fedimint': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'nostr-rs-relay': 18081,
|
||||
'indeedhub': 7777,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
/** Apps that need nginx proxy for iframe embedding.
|
||||
* IndeedHub loads via direct port 7777 — deploy script removes X-Frame-Options
|
||||
* from the container's internal nginx so iframe works on all servers. */
|
||||
const PROXY_APPS: Record<string, string> = {}
|
||||
|
||||
/** Nginx proxy paths — used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
|
||||
* On HTTP, direct port access is used instead (faster, no proxy). */
|
||||
const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'electrs': '/app/electrs/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'electrumx': '/app/electrs/',
|
||||
'electrs': '/app/electrs/',
|
||||
'mempool-electrs': '/app/electrs/',
|
||||
'mempool': '/app/mempool/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'grafana': '/app/grafana/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'archy-mempool-web': '/app/mempool/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'searxng': '/app/searxng/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'ollama': '/app/ollama/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'penpot': '/app/penpot/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'immich': '/app/immich/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'immich_server': '/app/immich/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
'endurain': '/app/endurain/',
|
||||
'indeedhub': '/app/indeedhub/',
|
||||
'dwn': '/app/dwn/',
|
||||
'endurain': '/app/endurain/',
|
||||
}
|
||||
|
||||
/** External HTTPS apps — always loaded directly */
|
||||
const EXTERNAL_URLS: Record<string, string> = {
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
@ -286,15 +360,47 @@ const APP_TITLES: Record<string, string> = {
|
||||
|
||||
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
|
||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||
const NEW_TAB_APPS = new Set([
|
||||
'btcpay-server', // X-Frame-Options: DENY
|
||||
'grafana', // X-Frame-Options: deny
|
||||
'photoprism', // X-Frame-Options: DENY
|
||||
'homeassistant', // X-Frame-Options: SAMEORIGIN
|
||||
'vaultwarden', // X-Frame-Options: SAMEORIGIN
|
||||
'nextcloud', // X-Frame-Options: SAMEORIGIN
|
||||
'uptime-kuma', // X-Frame-Options: SAMEORIGIN
|
||||
'penpot', // Not reachable / blocks iframe
|
||||
])
|
||||
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
const url = APP_URLS[appId.value]
|
||||
if (!url) return ''
|
||||
// Proxy paths — same origin
|
||||
if (url.startsWith('/')) return `${window.location.origin}${url}`
|
||||
// External HTTPS sites — direct
|
||||
if (url.startsWith('https://')) return url
|
||||
// Fallback: localhost port URLs (shouldn't reach here normally)
|
||||
return url.replace('localhost', window.location.hostname)
|
||||
const id = appId.value
|
||||
|
||||
// External HTTPS apps — iframe overlay
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
|
||||
const proxyPath = PROXY_APPS[id]
|
||||
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
||||
|
||||
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
|
||||
if (window.location.protocol === 'https:') {
|
||||
const httpsProxy = HTTPS_PROXY_PATHS[id]
|
||||
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
|
||||
}
|
||||
|
||||
// HTTP: direct port access (faster, no proxy overhead)
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
let base = `http://${window.location.hostname}:${port}`
|
||||
|
||||
// Append sub-path from query param (e.g. ?path=/tx/abc123)
|
||||
const subpath = route.query.path as string | undefined
|
||||
if (subpath) base += subpath
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Identity ---
|
||||
@ -325,6 +431,9 @@ function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
storeIdentity(identity)
|
||||
sendIdentity(identity)
|
||||
// NIP-98 auto-login disabled — apps like IndeedHub have their own login flow
|
||||
// that properly sets up internal account state. We provide window.nostr via
|
||||
// nostr-provider.js so the app's built-in "Sign In" button works.
|
||||
}
|
||||
|
||||
async function sendIdentity(identity: SelectedIdentity) {
|
||||
@ -339,6 +448,8 @@ async function sendIdentity(identity: SelectedIdentity) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// NIP-98 auto-login removed — apps handle their own login via window.nostr (NIP-07)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
function onLoad() {
|
||||
@ -393,7 +504,7 @@ function startLoadTimeout() {
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
goBack()
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
@ -408,14 +519,14 @@ function iframeGoForward() {
|
||||
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
router.back()
|
||||
function handleBackdropClick() {
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
router.push({ name: 'apps' })
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.push({ name: 'apps' })
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@ -463,12 +574,18 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
const identityId = getStoredIdentity()?.id || null
|
||||
const storedIdentity = getStoredIdentity()
|
||||
const identityId = storedIdentity?.id || null
|
||||
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
if (method === 'getPublicKey') {
|
||||
if (identityId) {
|
||||
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
|
||||
if (storedIdentity?.nostr_pubkey) {
|
||||
result = storedIdentity.nostr_pubkey
|
||||
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
|
||||
} else if (identityId) {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
@ -476,11 +593,13 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
|
||||
if (identityId) {
|
||||
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
|
||||
} else {
|
||||
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
}
|
||||
console.log('[NIP-07] signEvent OK')
|
||||
} else if (method === 'getRelays') { result = {} }
|
||||
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
@ -489,23 +608,30 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
|
||||
source.postMessage({ type: 'nostr-response', id, result }, '*')
|
||||
} catch (err) {
|
||||
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) — open in new tab, close session
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
document.addEventListener('click', onClickOutside)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
// Known blocked apps — show fallback immediately
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
startLoadTimeout()
|
||||
}
|
||||
// Enter fullscreen if that's the stored mode
|
||||
if (displayMode.value === 'fullscreen') {
|
||||
requestAnimationFrame(() => {
|
||||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||||
@ -528,19 +654,18 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
|
||||
.app-session-backdrop-panel {
|
||||
/* Inline panel mode — fills content area, no blur, original layout */
|
||||
.app-session-backdrop-inline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-session-inpanel {
|
||||
.app-session-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@ -550,10 +675,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-panel {
|
||||
.app-session-backdrop-inline {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.app-session-inpanel {
|
||||
.app-session-inline {
|
||||
border-radius: 1rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
|
||||
@ -81,8 +81,9 @@
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace')) }"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
@click="appLauncher.closePanel()"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -292,6 +293,13 @@
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
||||
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
|
||||
@ -308,11 +316,12 @@
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="appLauncher.closePanel()"
|
||||
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
|
||||
:class="{
|
||||
'nav-tab-active': item.isCombined
|
||||
? (item.path === '/dashboard/apps'
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
|
||||
: (route.path.includes('/cloud') || route.path.includes('/server')))
|
||||
: undefined
|
||||
}"
|
||||
@ -385,6 +394,8 @@ import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
@ -405,6 +416,7 @@ const chatFullscreen = computed(() => route.path === '/dashboard/chat')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const web5Badge = useWeb5BadgeStore()
|
||||
|
||||
@ -1257,6 +1269,27 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
}
|
||||
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
/* Panel mode app session — fills content area, sidebar stays untouched */
|
||||
.app-panel-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.panel-slide-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.panel-slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.panel-slide-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.panel-slide-leave-to {
|
||||
transform: translateX(40px) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@ -96,14 +96,28 @@
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.installed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
||||
<button
|
||||
v-for="app in quickLaunchApps"
|
||||
:key="app.id"
|
||||
@click="useAppLauncherStore().openSession(app.id)"
|
||||
class="group"
|
||||
:title="app.name"
|
||||
>
|
||||
<div
|
||||
class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center"
|
||||
:style="app.bg ? { background: app.bg } : {}"
|
||||
:class="{ 'bg-white/5': !app.bg }"
|
||||
>
|
||||
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('home.browseStore') }}
|
||||
@ -476,6 +490,12 @@ const runningCount = computed(() =>
|
||||
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
|
||||
)
|
||||
|
||||
const quickLaunchApps = [
|
||||
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
|
||||
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
|
||||
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
|
||||
]
|
||||
|
||||
// Network card computed values
|
||||
const servicesAllRunning = computed(() =>
|
||||
appCount.value > 0 && runningCount.value === appCount.value
|
||||
|
||||
@ -203,7 +203,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@ -214,6 +214,10 @@ import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const currentRoute = useRoute()
|
||||
|
||||
/** After login, redirect to the intended page or default to home */
|
||||
const loginRedirectTo = computed(() => (currentRoute.query.redirect as string) || '/dashboard')
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
@ -377,8 +381,8 @@ async function handleSetup() {
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await store.login(password.value)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
@ -421,8 +425,8 @@ async function handleLogin() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
@ -456,8 +460,8 @@ async function handleTotpVerify() {
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace({ name: 'home' }).catch(() => {
|
||||
window.location.href = '/dashboard'
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
|
||||
@ -445,12 +445,12 @@ const features = computed(() => {
|
||||
|
||||
/** App dependency definitions */
|
||||
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
|
||||
'electrs': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
'mempool': [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' },
|
||||
{ id: 'electrs', title: 'Electrs', dockerImage: 'docker.io/mempool/electrs:latest' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', dockerImage: 'docker.io/lukechilds/electrumx:v1.18.0' },
|
||||
],
|
||||
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
|
||||
}
|
||||
@ -533,7 +533,7 @@ async function installDependencies() {
|
||||
installError.value = null
|
||||
|
||||
try {
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrs)
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrumx)
|
||||
for (const dep of missingDeps) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
|
||||
@ -42,8 +42,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="autoAdvancing" class="text-lg text-white/80 mb-2">DID retrieved, continuing...</p>
|
||||
<p v-else class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your node's decentralized identifier
|
||||
</p>
|
||||
</div>
|
||||
@ -128,10 +127,8 @@ const generatedDid = ref<string>('')
|
||||
const nostrNpub = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const autoAdvancing = ref(false)
|
||||
const didCopied = ref(false)
|
||||
const npubCopied = ref(false)
|
||||
const elapsedSeconds = ref(0)
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
@ -141,7 +138,6 @@ function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
elapsedSeconds.value = secs
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
@ -179,7 +175,6 @@ async function fetchDid() {
|
||||
}
|
||||
}).catch(() => { /* Nostr key may not exist yet */ })
|
||||
|
||||
autoAdvanceAfterDelay()
|
||||
} catch {
|
||||
isGenerating.value = false
|
||||
if (!waitingForServer.value) {
|
||||
@ -190,13 +185,6 @@ async function fetchDid() {
|
||||
}
|
||||
}
|
||||
|
||||
function autoAdvanceAfterDelay() {
|
||||
autoAdvancing.value = true
|
||||
setTimeout(() => {
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = localStorage.getItem('neode_did')
|
||||
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
||||
|
||||
@ -119,7 +119,7 @@ async function signChallenge() {
|
||||
if (did) {
|
||||
const result = await rpcClient.call({
|
||||
method: 'identity.verify',
|
||||
params: { did, data: currentChallenge.value, signature: sig },
|
||||
params: { did, message: currentChallenge.value, signature: sig },
|
||||
}) as { valid: boolean }
|
||||
verified.value = result.valid !== false
|
||||
} else {
|
||||
|
||||
@ -328,12 +328,70 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.wallet') }}</h2>
|
||||
<p class="text-white/70 text-sm mb-4">{{ t('web5.walletSubtitle') }}</p>
|
||||
</div>
|
||||
<!-- Incoming Transactions Badge -->
|
||||
<button
|
||||
v-if="incomingTxCount > 0"
|
||||
@click="showIncomingTxPanel = !showIncomingTxPanel"
|
||||
class="incoming-tx-badge shrink-0"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
<span>Incoming {{ incomingTxCount }}</span>
|
||||
<span class="incoming-tx-ping"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<transition name="incoming-tx-slide">
|
||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
|
||||
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in incomingTransactions"
|
||||
:key="tx.tx_hash"
|
||||
class="incoming-tx-row"
|
||||
@click="openInMempool(tx.tx_hash)"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="incoming-tx-icon" :class="tx.num_confirmations === 0 ? 'incoming-tx-icon-pending' : 'incoming-tx-icon-confirmed'">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
|
||||
>
|
||||
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
||||
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<!-- On-chain Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
@ -1149,20 +1207,24 @@
|
||||
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
|
||||
:style="{ '--stagger-index': idx }"
|
||||
>
|
||||
<!-- Purpose Icon -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<svg class="w-5 h-5" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Avatar (clickable to edit profile) -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
<img v-if="identity.profile?.picture" :src="identity.profile.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
}">
|
||||
<span class="text-sm font-bold" :class="{
|
||||
'text-blue-400': identity.purpose === 'personal',
|
||||
'text-orange-400': identity.purpose === 'business',
|
||||
'text-purple-400': identity.purpose === 'anonymous',
|
||||
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
@ -1175,14 +1237,25 @@
|
||||
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
|
||||
}">{{ identity.purpose }}</span>
|
||||
</div>
|
||||
<p class="text-white/50 text-xs font-mono truncate mt-0.5" :title="identity.did">{{ identity.did }}</p>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
|
||||
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
|
||||
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
|
||||
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
|
||||
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
|
||||
@ -1201,6 +1274,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Create Identity Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
|
||||
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
|
||||
@ -1233,8 +1307,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
|
||||
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
|
||||
@ -1247,7 +1323,221 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Key Viewer Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
|
||||
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
|
||||
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
|
||||
}">
|
||||
<svg class="w-5 h-5" :class="{
|
||||
'text-blue-400': keyViewerIdentity.purpose === 'personal',
|
||||
'text-orange-400': keyViewerIdentity.purpose === 'business',
|
||||
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
|
||||
}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
|
||||
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Keys -->
|
||||
<div class="space-y-3 mb-5">
|
||||
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
Public Keys
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">DID (Ed25519)</span>
|
||||
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
|
||||
</div>
|
||||
<div class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
|
||||
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
|
||||
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
|
||||
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors flex items-center gap-1">
|
||||
{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Private Keys Section -->
|
||||
<div class="border-t border-white/10 pt-5">
|
||||
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
Private Keys
|
||||
</h3>
|
||||
|
||||
<!-- Locked state — password required -->
|
||||
<div v-if="!keyViewerPrivateKeys">
|
||||
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="keyViewerPassword"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
|
||||
@keydown.enter="unlockPrivateKeys"
|
||||
/>
|
||||
<button
|
||||
@click="unlockPrivateKeys"
|
||||
:disabled="!keyViewerPassword || keyViewerUnlocking"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50"
|
||||
>
|
||||
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unlocked state — show private keys -->
|
||||
<div v-else class="space-y-2">
|
||||
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
|
||||
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
|
||||
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
|
||||
</div>
|
||||
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
|
||||
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">
|
||||
{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
|
||||
</div>
|
||||
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">
|
||||
Lock private keys
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="flex justify-end mt-5">
|
||||
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Profile Editor Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img v-if="profileForm.picture" :src="profileForm.picture" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
|
||||
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} · {{ profileEditorIdentity.purpose }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Display Name</label>
|
||||
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
|
||||
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30 resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
|
||||
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
|
||||
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Website</label>
|
||||
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
|
||||
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
|
||||
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="profileError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-red-300 text-xs">{{ profileError }}</p>
|
||||
</div>
|
||||
<div v-if="profileSuccess" class="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded-lg">
|
||||
<p class="text-green-300 text-xs">{{ profileSuccess }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||
{{ profileSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">
|
||||
{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unified Send Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
|
||||
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
|
||||
@ -1346,8 +1636,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Unified Receive Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
|
||||
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
||||
@ -1411,6 +1703,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Decentralized Web Node (DWN) -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
@ -1641,6 +1934,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Domains Management Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@ -1716,8 +2010,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Relay Management Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@ -1759,6 +2055,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Identity Toast -->
|
||||
<Transition name="content-fade">
|
||||
@ -1770,7 +2067,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@ -2194,6 +2491,62 @@ const connectingWallet = ref(false)
|
||||
const lndOnchainBalance = ref(0)
|
||||
const lndChannelBalance = ref(0)
|
||||
|
||||
// Incoming Transactions
|
||||
interface WalletTransaction {
|
||||
tx_hash: string
|
||||
amount_sats: number
|
||||
direction: 'incoming' | 'outgoing'
|
||||
num_confirmations: number
|
||||
time_stamp: number
|
||||
total_fees: number
|
||||
dest_addresses: string[]
|
||||
label: string
|
||||
block_height: number
|
||||
}
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
const incomingTransactions = computed(() =>
|
||||
walletTransactions.value.filter(tx => tx.direction === 'incoming')
|
||||
)
|
||||
const incomingTxCount = computed(() => incomingTransactions.value.length)
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
|
||||
walletTransactions.value = res.transactions || []
|
||||
// Auto-show panel when new unconfirmed incoming txs appear
|
||||
const pending = res.incoming_pending_count || 0
|
||||
if (pending > 0 && !showIncomingTxPanel.value) {
|
||||
showIncomingTxPanel.value = true
|
||||
}
|
||||
} catch {
|
||||
walletTransactions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatTxTime(timestamp: number): string {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function openInMempool(txHash: string) {
|
||||
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
|
||||
}
|
||||
|
||||
// Auto-refresh wallet data every 30s
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Nostr Relays
|
||||
interface NostrRelayData {
|
||||
url: string
|
||||
@ -2988,6 +3341,16 @@ function copyOnionAddress() {
|
||||
}
|
||||
|
||||
// --- Identity Management ---
|
||||
interface IdentityProfile {
|
||||
display_name?: string
|
||||
about?: string
|
||||
picture?: string
|
||||
banner?: string
|
||||
website?: string
|
||||
nip05?: string
|
||||
lud16?: string
|
||||
}
|
||||
|
||||
interface ManagedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
@ -2996,6 +3359,131 @@ interface ManagedIdentity {
|
||||
did: string
|
||||
created_at: string
|
||||
is_default: boolean
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
profile?: IdentityProfile
|
||||
}
|
||||
|
||||
// --- Key Viewer Modal ---
|
||||
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
|
||||
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
|
||||
const keyViewerPassword = ref('')
|
||||
const keyViewerUnlocking = ref(false)
|
||||
const keyViewerError = ref('')
|
||||
const keyViewerCopied = ref<string | null>(null)
|
||||
|
||||
function openKeyViewer(identity: ManagedIdentity) {
|
||||
keyViewerIdentity.value = identity
|
||||
keyViewerPrivateKeys.value = null
|
||||
keyViewerPassword.value = ''
|
||||
keyViewerError.value = ''
|
||||
}
|
||||
|
||||
function closeKeyViewer() {
|
||||
// Clear sensitive data immediately
|
||||
keyViewerPrivateKeys.value = null
|
||||
keyViewerPassword.value = ''
|
||||
keyViewerError.value = ''
|
||||
keyViewerIdentity.value = null
|
||||
}
|
||||
|
||||
async function unlockPrivateKeys() {
|
||||
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
|
||||
keyViewerUnlocking.value = true
|
||||
keyViewerError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
ed25519_secret_hex: string
|
||||
nostr_secret_hex: string | null
|
||||
nostr_nsec: string | null
|
||||
}>({
|
||||
method: 'identity.export-keys',
|
||||
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
|
||||
})
|
||||
keyViewerPrivateKeys.value = {
|
||||
ed25519_secret_hex: res.ed25519_secret_hex,
|
||||
nostr_secret_hex: res.nostr_secret_hex || '',
|
||||
nostr_nsec: res.nostr_nsec || '',
|
||||
}
|
||||
keyViewerPassword.value = '' // Clear password from memory immediately
|
||||
} catch (err: unknown) {
|
||||
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
|
||||
} finally {
|
||||
keyViewerUnlocking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyKeyValue(label: string, value: string) {
|
||||
safeClipboardWrite(value)
|
||||
keyViewerCopied.value = label
|
||||
setTimeout(() => { keyViewerCopied.value = null }, 2000)
|
||||
}
|
||||
|
||||
// --- Profile Editor ---
|
||||
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
|
||||
const profileForm = ref<IdentityProfile>({})
|
||||
const profileSaving = ref(false)
|
||||
const profilePublishing = ref(false)
|
||||
const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
|
||||
function openProfileEditor(identity: ManagedIdentity) {
|
||||
profileEditorIdentity.value = identity
|
||||
profileForm.value = { ...identity.profile }
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
}
|
||||
|
||||
function closeProfileEditor() {
|
||||
profileEditorIdentity.value = null
|
||||
profileForm.value = {}
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!profileEditorIdentity.value || profileSaving.value) return
|
||||
profileSaving.value = true
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.update-profile',
|
||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
||||
})
|
||||
await loadIdentities()
|
||||
profileSuccess.value = 'Profile saved'
|
||||
setTimeout(() => { profileSuccess.value = '' }, 3000)
|
||||
} catch (err: unknown) {
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to save'
|
||||
} finally {
|
||||
profileSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishProfile() {
|
||||
if (!profileEditorIdentity.value || profilePublishing.value) return
|
||||
profilePublishing.value = true
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
try {
|
||||
// Save first, then publish
|
||||
await rpcClient.call({
|
||||
method: 'identity.update-profile',
|
||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
||||
})
|
||||
const res = await rpcClient.call<{ event_id: string }>({
|
||||
method: 'identity.publish-profile',
|
||||
params: { id: profileEditorIdentity.value.id },
|
||||
})
|
||||
await loadIdentities()
|
||||
profileSuccess.value = `Published to relay (${res.event_id.slice(0, 12)}...)`
|
||||
setTimeout(() => { profileSuccess.value = '' }, 5000)
|
||||
} catch (err: unknown) {
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
|
||||
} finally {
|
||||
profilePublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const managedIdentities = ref<ManagedIdentity[]>([])
|
||||
@ -3090,7 +3578,14 @@ onMounted(() => {
|
||||
loadNostrRelays()
|
||||
loadCredentials()
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
detectHardwareWallets()
|
||||
// Auto-refresh wallet balances and transactions every 30s
|
||||
walletRefreshInterval = setInterval(() => {
|
||||
loadLndBalances()
|
||||
loadTransactions()
|
||||
loadEcashBalance()
|
||||
}, 30000)
|
||||
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
|
||||
if (route.query.tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
@ -3101,6 +3596,13 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (walletRefreshInterval) {
|
||||
clearInterval(walletRefreshInterval)
|
||||
walletRefreshInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
|
||||
@ -607,10 +607,10 @@ PYEOF
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# Rebuild and recreate Electrs UI container (port 50002)
|
||||
echo "$(timestamp) Rebuilding Electrs UI..."
|
||||
# Rebuild and recreate ElectrumX UI container (port 50002)
|
||||
echo "$(timestamp) Rebuilding ElectrumX UI..."
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t electrs-ui:latest . || sudo docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||
echo " Recreating Electrs UI container (port 50002, host network)..."
|
||||
echo " Recreating ElectrumX UI container (port 50002, host network)..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
@ -621,7 +621,22 @@ PYEOF
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# Bitcoin Knots: required for Mempool, Electrs, BTCPay, Fedimint
|
||||
# Rebuild and recreate Bitcoin UI container (host network, port 8334 in nginx.conf)
|
||||
# Host network required: bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332
|
||||
echo "$(timestamp) Rebuilding Bitcoin UI..."
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/bitcoin-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t bitcoin-ui:latest . || sudo docker build --no-cache -t bitcoin-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||
echo " Recreating Bitcoin UI container (port 8334, host network)..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
for c in $(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i bitcoin-ui); do
|
||||
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||
done
|
||||
sudo $DOCKER run -d --name archy-bitcoin-ui --network host --restart unless-stopped bitcoin-ui:latest
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# Bitcoin Knots: required for Mempool, ElectrumX, BTCPay, Fedimint
|
||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||
echo "$(timestamp) Ensuring Bitcoin Knots..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
@ -632,7 +647,7 @@ PYEOF
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then
|
||||
echo ' Creating Bitcoin Knots (mainnet, archipelago RPC)...'
|
||||
sudo mkdir -p /var/lib/archipelago/bitcoin
|
||||
# Demo mode: prune=550 saves ~194GB disk, but disables txindex (incompatible with electrs)
|
||||
# Demo mode: prune=550 saves ~194GB disk, but disables txindex (incompatible with electrumx)
|
||||
if [ "$DEMO" = "true" ]; then
|
||||
BTC_EXTRA_ARGS="-prune=550"
|
||||
BTC_DBCACHE=512
|
||||
@ -664,7 +679,7 @@ PYEOF
|
||||
TARGET_IP='$TARGET_IP'
|
||||
NET_OPT='--network archy-net'
|
||||
# Clean any duplicate/old mempool containers (user may have two versions)
|
||||
# EXCLUDE mempool-electrs - indexing takes days, do not recreate on every deploy
|
||||
# EXCLUDE electrumx/mempool-electrs - indexing takes days, do not recreate on every deploy
|
||||
for c in mempool mempool-api mempool-web archy-mempool-api archy-mempool-web; do
|
||||
sudo \$DOCKER stop \$c 2>/dev/null
|
||||
sudo \$DOCKER rm -f \$c 2>/dev/null
|
||||
@ -686,34 +701,28 @@ PYEOF
|
||||
MYSQL_CNT=\${MYSQL_CNT:-archy-mempool-db}
|
||||
# Ensure DB is on archy-net so mempool-api can resolve it
|
||||
sudo \$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true
|
||||
# Create mempool-electrs ONLY if missing - do NOT recreate (indexing takes days, data is 800GB+)
|
||||
# One-time migration: if on bridge (wrong network), recreate with archy-net so it can reach bitcoin-knots
|
||||
# Stop and remove old mempool-electrs if present (replaced by electrumx)
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
MEMPOOL_ELECTRS_NET=\$(sudo \$DOCKER inspect mempool-electrs --format '{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}' 2>/dev/null || true)
|
||||
if [ \"\$MEMPOOL_ELECTRS_NET\" = \"bridge\" ] || [ \"\$MEMPOOL_ELECTRS_NET\" = \"\" ]; then
|
||||
echo ' Migrating mempool-electrs to archy-net (preserving 800GB+ index)...'
|
||||
sudo \$DOCKER stop mempool-electrs 2>/dev/null
|
||||
sudo \$DOCKER rm mempool-electrs 2>/dev/null
|
||||
fi
|
||||
echo ' Removing old mempool-electrs (replaced by ElectrumX)...'
|
||||
sudo \$DOCKER stop mempool-electrs 2>/dev/null
|
||||
sudo \$DOCKER rm -f mempool-electrs 2>/dev/null
|
||||
fi
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
echo ' Starting existing mempool-electrs (preserving index)...'
|
||||
sudo \$DOCKER start mempool-electrs 2>/dev/null || true
|
||||
# Create electrumx ONLY if missing - do NOT recreate (indexing takes days)
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
|
||||
echo ' Starting existing electrumx (preserving index)...'
|
||||
sudo \$DOCKER start electrumx 2>/dev/null || true
|
||||
else
|
||||
echo ' Creating mempool-electrs (indexer - may take days to sync, do not recreate)...'
|
||||
sudo mkdir -p /var/lib/archipelago/mempool-electrs
|
||||
# Use archy-net + bitcoin-knots for reliable Bitcoin connectivity (not host IP from bridge)
|
||||
sudo \$DOCKER run -d --name mempool-electrs --restart unless-stopped \$NET_OPT \
|
||||
echo ' Creating electrumx (indexer - may take days to sync, do not recreate)...'
|
||||
sudo mkdir -p /var/lib/archipelago/electrumx
|
||||
sudo \$DOCKER run -d --name electrumx --restart unless-stopped \$NET_OPT \
|
||||
-p 50001:50001 \
|
||||
-v /var/lib/archipelago/mempool-electrs:/data \
|
||||
docker.io/mempool/electrs:latest \
|
||||
--daemon-rpc-addr bitcoin-knots:8332 \
|
||||
--cookie archipelago:archipelago123 \
|
||||
--jsonrpc-import \
|
||||
--electrum-rpc-addr 0.0.0.0:50001 \
|
||||
--db-dir /data \
|
||||
--lightmode
|
||||
-v /var/lib/archipelago/electrumx:/data \
|
||||
-e DAEMON_URL=http://archipelago:archipelago123@bitcoin-knots:8332/ \
|
||||
-e COIN=Bitcoin \
|
||||
-e DB_DIRECTORY=/data \
|
||||
-e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \
|
||||
docker.io/lukechilds/electrumx:v1.18.0
|
||||
fi
|
||||
fi
|
||||
# Create/recreate mempool-api (backend on 8999) - required for mempool to work
|
||||
@ -729,7 +738,7 @@ PYEOF
|
||||
-p 8999:8999 \
|
||||
-v /var/lib/archipelago/mempool:/data \
|
||||
-e MEMPOOL_BACKEND=electrum \
|
||||
-e ELECTRUM_HOST=mempool-electrs \
|
||||
-e ELECTRUM_HOST=electrumx \
|
||||
-e ELECTRUM_PORT=50001 \
|
||||
-e ELECTRUM_TLS_ENABLED=false \
|
||||
-e CORE_RPC_HOST=\$TARGET_IP \
|
||||
@ -896,7 +905,7 @@ import json
|
||||
services = [
|
||||
{"name": "archipelago", "local_port": 80, "enabled": True},
|
||||
{"name": "bitcoin", "local_port": 8333, "enabled": True},
|
||||
{"name": "electrs", "local_port": 50001, "enabled": True},
|
||||
{"name": "electrumx", "local_port": 50001, "enabled": True},
|
||||
{"name": "lnd", "local_port": 9735, "enabled": True},
|
||||
{"name": "btcpay", "local_port": 23000, "enabled": True},
|
||||
{"name": "mempool", "local_port": 4080, "enabled": True},
|
||||
@ -923,7 +932,7 @@ try:
|
||||
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
||||
lines.append("")
|
||||
except Exception:
|
||||
for n, p in [("archipelago",80),("bitcoin",8333),("electrs",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
|
||||
for n, p in [("archipelago",80),("bitcoin",8333),("electrumx",50001),("lnd",9735),("btcpay",23000),("mempool",4080),("fedimint",8175)]:
|
||||
lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n)
|
||||
lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p))
|
||||
lines.append("")
|
||||
@ -1231,6 +1240,44 @@ LNDCONF
|
||||
|
||||
fi # end FRONTEND_ONLY guard
|
||||
|
||||
# Ensure UFW allows forwarded traffic (required for podman container port access from LAN)
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
if grep -q "DEFAULT_FORWARD_POLICY=\"DROP\"" /etc/default/ufw 2>/dev/null; then
|
||||
sudo sed -i "s/DEFAULT_FORWARD_POLICY=\"DROP\"/DEFAULT_FORWARD_POLICY=\"ACCEPT\"/" /etc/default/ufw
|
||||
sudo ufw reload 2>/dev/null
|
||||
echo " Fixed UFW forward policy (was DROP, now ACCEPT)"
|
||||
fi
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Fix IndeedHub for iframe + NIP-07: remove X-Frame-Options, inject nostr-provider.js
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
if sudo podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then
|
||||
CHANGED=false
|
||||
# Remove X-Frame-Options so iframe works
|
||||
if sudo podman exec indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then
|
||||
sudo podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf
|
||||
CHANGED=true
|
||||
echo " Removed X-Frame-Options from IndeedHub"
|
||||
fi
|
||||
# Inject nostr-provider.js for NIP-07 signing
|
||||
if ! sudo podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then
|
||||
sudo podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null
|
||||
echo " Copied nostr-provider.js into IndeedHub"
|
||||
fi
|
||||
if ! sudo podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
|
||||
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
|
||||
sed -i "/try_files.*index.html/i\\ sub_filter_once on;\n sub_filter '"'"'</head>'"'"' '"'"'<script src=\"/nostr-provider.js\"></script></head>'"'"';" /tmp/ih-nginx.conf
|
||||
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null
|
||||
rm -f /tmp/ih-nginx.conf
|
||||
CHANGED=true
|
||||
echo " Injected nostr-provider.js into IndeedHub nginx"
|
||||
fi
|
||||
if [ "$CHANGED" = true ]; then
|
||||
sudo podman exec indeedhub nginx -s reload 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Post-deploy health check — wait up to 60s for server to come healthy
|
||||
echo ""
|
||||
echo "$(timestamp) 🩺 Post-deploy health check..."
|
||||
|
||||
@ -66,19 +66,30 @@ $DOCKER network create archy-net 2>/dev/null || true
|
||||
log "=== Tier 1: Databases & Core Infrastructure ==="
|
||||
|
||||
# 1. Bitcoin Knots (matches deploy exactly)
|
||||
# Auto-detect: if disk < 1TB, use pruning to prevent disk-full crashes
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then
|
||||
log "Creating Bitcoin Knots..."
|
||||
mkdir -p /var/lib/archipelago/bitcoin
|
||||
DISK_GB=$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
BTC_EXTRA_ARGS="-prune=550"
|
||||
BTC_DBCACHE=512
|
||||
log " Small disk (${DISK_GB}GB) — enabling pruning"
|
||||
else
|
||||
BTC_EXTRA_ARGS="-txindex=1"
|
||||
BTC_DBCACHE=4096
|
||||
log " Large disk (${DISK_GB}GB) — enabling txindex"
|
||||
fi
|
||||
if $DOCKER run -d --name bitcoin-knots --restart unless-stopped --network archy-net \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 8332:8332 -p 8333:8333 \
|
||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||
docker.io/bitcoinknots/bitcoin:latest \
|
||||
-server=1 -txindex=1 \
|
||||
-server=1 $BTC_EXTRA_ARGS \
|
||||
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \
|
||||
-rpcuser=archipelago -rpcpassword=archipelago123 \
|
||||
-dbcache=4096 2>>"$LOG"; then
|
||||
-dbcache=$BTC_DBCACHE 2>>"$LOG"; then
|
||||
log "Bitcoin Knots started"
|
||||
else
|
||||
log "Bitcoin Knots failed (may already exist)"
|
||||
@ -105,17 +116,18 @@ MYSQL_CNT=$($DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mem
|
||||
MYSQL_CNT=${MYSQL_CNT:-archy-mempool-db}
|
||||
$DOCKER network connect archy-net "$MYSQL_CNT" 2>/dev/null || true
|
||||
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
$DOCKER start mempool-electrs 2>/dev/null || true
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
|
||||
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then
|
||||
$DOCKER start electrumx 2>/dev/null || true
|
||||
else
|
||||
log "Creating mempool-electrs..."
|
||||
mkdir -p /var/lib/archipelago/mempool-electrs
|
||||
$DOCKER run -d --name mempool-electrs --restart unless-stopped --network archy-net \
|
||||
-p 50001:50001 -v /var/lib/archipelago/mempool-electrs:/data \
|
||||
docker.io/mempool/electrs:latest \
|
||||
--daemon-rpc-addr bitcoin-knots:8332 --cookie archipelago:archipelago123 \
|
||||
--jsonrpc-import --electrum-rpc-addr 0.0.0.0:50001 --db-dir /data --lightmode 2>>"$LOG" || true
|
||||
log "Creating electrumx..."
|
||||
mkdir -p /var/lib/archipelago/electrumx
|
||||
$DOCKER run -d --name electrumx --restart unless-stopped --network archy-net \
|
||||
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
|
||||
-e DAEMON_URL=http://archipelago:archipelago123@bitcoin-knots:8332/ \
|
||||
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
||||
-e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \
|
||||
docker.io/lukechilds/electrumx:v1.18.0 2>>"$LOG" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -124,7 +136,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
|
||||
mkdir -p /var/lib/archipelago/mempool
|
||||
$DOCKER run -d --name mempool-api --restart unless-stopped --network archy-net \
|
||||
-p 8999:8999 -v /var/lib/archipelago/mempool:/data \
|
||||
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=mempool-electrs -e ELECTRUM_PORT=50001 \
|
||||
-e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \
|
||||
-e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST="$TARGET_IP" -e CORE_RPC_PORT=8332 \
|
||||
-e CORE_RPC_USERNAME=archipelago -e CORE_RPC_PASSWORD=archipelago123 \
|
||||
-e DATABASE_ENABLED=true -e DATABASE_HOST="$MYSQL_CNT" -e DATABASE_DATABASE=mempool \
|
||||
@ -139,21 +151,21 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-mempool-web|
|
||||
docker.io/mempool/frontend:v2.5.0 2>>"$LOG" || true
|
||||
fi
|
||||
|
||||
# 2b. Electrs UI (status dashboard on port 50002, host network for backend access)
|
||||
# 2b. ElectrumX UI (status dashboard on port 50002, host network for backend access)
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrs-ui; then
|
||||
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'electrs-ui'; then
|
||||
log "Starting Electrs UI from pre-built image..."
|
||||
log "Starting ElectrumX UI from pre-built image..."
|
||||
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
|
||||
localhost/electrs-ui:latest 2>>"$LOG" || \
|
||||
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
|
||||
electrs-ui:latest 2>>"$LOG" || true
|
||||
elif [ -d /opt/archipelago/docker/electrs-ui ]; then
|
||||
log "Building and starting Electrs UI from source..."
|
||||
log "Building and starting ElectrumX UI from source..."
|
||||
$DOCKER build -t electrs-ui:latest /opt/archipelago/docker/electrs-ui 2>>"$LOG" && \
|
||||
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped \
|
||||
electrs-ui:latest 2>>"$LOG" || true
|
||||
else
|
||||
log "Electrs UI: no image or source found, skipping"
|
||||
log "ElectrumX UI: no image or source found, skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -571,9 +583,19 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
|
||||
$DOCKER run -d --name indeedhub --restart unless-stopped \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
|
||||
-p 8190:3000 \
|
||||
-p 7777:7777 \
|
||||
-e NODE_ENV=production -e NEXT_TELEMETRY_DISABLED=1 \
|
||||
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
|
||||
# Fix IndeedHub for iframe: remove X-Frame-Options so it loads in Archipelago panel
|
||||
sleep 2
|
||||
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
|
||||
$DOCKER exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
|
||||
$DOCKER cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
|
||||
fi
|
||||
$DOCKER exec indeedhub nginx -s reload 2>/dev/null || true
|
||||
log "Applied IndeedHub iframe fix (removed X-Frame-Options)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -584,7 +606,7 @@ for ui in bitcoin-ui lnd-ui; do
|
||||
continue
|
||||
fi
|
||||
case $ui in
|
||||
bitcoin-ui) PORT_ARG="-p 8334:80"; NET_ARG="" ;;
|
||||
bitcoin-ui) PORT_ARG=""; NET_ARG="--network host" ;; # host network: proxies Bitcoin RPC at 127.0.0.1:8332
|
||||
lnd-ui) PORT_ARG="-p 8081:80"; NET_ARG="" ;;
|
||||
esac
|
||||
CONTAINER_NAME="archy-$ui"
|
||||
@ -593,10 +615,17 @@ for ui in bitcoin-ui lnd-ui; do
|
||||
IMG=$($DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep "$ui" | head -1)
|
||||
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$IMG" 2>>"$LOG" || true
|
||||
elif [ -d "/opt/archipelago/docker/$ui" ]; then
|
||||
log "Building $ui from source..."
|
||||
log "Building $ui from source (/opt/archipelago/docker/$ui)..."
|
||||
if $DOCKER build -t "$ui:latest" "/opt/archipelago/docker/$ui" 2>>"$LOG"; then
|
||||
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$ui:latest" 2>>"$LOG" || true
|
||||
fi
|
||||
elif [ -d "/home/archipelago/archy/docker/$ui" ]; then
|
||||
log "Building $ui from source (/home/archipelago/archy/docker/$ui)..."
|
||||
if $DOCKER build -t "$ui:latest" "/home/archipelago/archy/docker/$ui" 2>>"$LOG"; then
|
||||
$DOCKER run -d --name "$CONTAINER_NAME" $PORT_ARG --restart unless-stopped $NET_ARG "$ui:latest" 2>>"$LOG" || true
|
||||
fi
|
||||
else
|
||||
log "$ui: no image or source found, skipping"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user