feat: Phase 4 backend hardening — container reliability + security audit

Container Management (CONT-01 through CONT-06):
- Fix needs_archy_net: add lnd, nbxplorer to archy-net list
- Add StartupTier dependency ordering to health monitor (DB→Core→Dependent→App→UI)
- Add exponential backoff (10s/30s/90s) with 1hr stability reset
- Add get_health_check_args() with health checks for 20+ apps
- Add get_memory_limit() with per-app limits (128m-4g vs blanket 2g)
- Create docs/network-topology.md
- Fix fedimint containers on both nodes (moved to archy-net)

Security Audit (SEC-01 through SEC-06):
- Add sanitize_error_message() — strips internal paths from RPC errors
- Add validate_identity_id() — blocks path traversal on identity operations
- Add validate_did() — blocks path traversal on federation operations
- Add message size limits: node-send-message (1MB), dwn.write-message (10MB)
- Add rate limits for federation endpoints (join: 5/60s, invite: 10/300s)
- Configure journald (500MB max, 7 day retention) on both nodes
- Add /etc/logrotate.d/archipelago for backend + crowdsec logs
- Verify all 4 nginx security headers on both nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-14 02:45:28 +00:00
parent f9a47a2602
commit 6335ea17ee
10 changed files with 593 additions and 83 deletions

View File

@@ -151,6 +151,14 @@ impl RpcHandler {
let data_format = params["dataFormat"].as_str();
let data = params.get("data").cloned();
// Limit data size to 10MB to prevent disk exhaustion
if let Some(ref d) = data {
let data_str = d.to_string();
if data_str.len() > 10_485_760 {
anyhow::bail!("Message data too large (max 10MB)");
}
}
let store = DwnStore::new(&self.config.data_dir).await?;
let message = store
.write_message(author, protocol, schema, data_format, data)

View File

@@ -4,6 +4,20 @@ use crate::identity;
use anyhow::Result;
use tracing::info;
/// Validate a DID parameter: must start with "did:", max 256 chars, no path traversal.
fn validate_did(did: &str) -> Result<()> {
if did.is_empty() || did.len() > 256 {
anyhow::bail!("Invalid DID: must be 1-256 characters");
}
if !did.starts_with("did:") {
anyhow::bail!("Invalid DID: must start with 'did:'");
}
if did.contains("..") || did.contains('/') || did.contains('\\') || did.contains('\0') {
anyhow::bail!("Invalid DID: contains forbidden characters");
}
Ok(())
}
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(super) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
@@ -107,6 +121,7 @@ impl RpcHandler {
.get("did")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
validate_did(did)?;
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
info!(did = %did, "Removed node from federation");
@@ -127,6 +142,7 @@ impl RpcHandler {
.get("did")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
validate_did(did)?;
let trust_str = params
.get("trust_level")
.and_then(|v| v.as_str())

View File

@@ -5,6 +5,20 @@ use crate::identity_manager::{IdentityManager, IdentityPurpose};
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
/// Validate an identity ID: alphanumeric, hyphens, underscores, 1-128 chars, no path traversal.
fn validate_identity_id(id: &str) -> Result<()> {
if id.is_empty() || id.len() > 128 {
anyhow::bail!("Invalid identity id: must be 1-128 characters");
}
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
anyhow::bail!("Invalid identity id: contains forbidden characters");
}
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
}
Ok(())
}
impl RpcHandler {
/// List all identities with their default status.
pub(super) async fn handle_identity_list(
@@ -83,6 +97,7 @@ impl RpcHandler {
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
let record = manager.get(id).await?;
@@ -112,6 +127,7 @@ impl RpcHandler {
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
manager.delete(id).await?;
@@ -129,6 +145,7 @@ impl RpcHandler {
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let manager = IdentityManager::new(&self.config.data_dir).await?;
manager.set_default(id).await?;

View File

@@ -66,6 +66,42 @@ struct RpcError {
/// Default dev password when no user is set up (matches mock-backend).
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
/// Sanitize error messages before returning to clients.
/// Keeps user-facing validation errors but strips internal system details.
fn sanitize_error_message(msg: &str) -> String {
// Allow known validation errors through (these are user-actionable)
let user_facing_prefixes = [
"Invalid",
"Missing",
"Not found",
"Already exists",
"Rate limit",
"Unauthorized",
"Forbidden",
"Not supported",
"requires",
"must be",
"cannot",
"Password",
"Session",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) || msg.contains(prefix) {
// Truncate long messages and strip file paths
let sanitized = msg.replace("/var/lib/archipelago/", "[data]/")
.replace("/usr/local/bin/", "[bin]/")
.replace("/etc/", "[config]/");
return if sanitized.len() > 200 {
format!("{}...", &sanitized[..200])
} else {
sanitized
};
}
}
// For all other errors, return a generic message
"Operation failed. Check server logs for details.".to_string()
}
/// Methods that do not require a valid session cookie.
const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login",
@@ -472,6 +508,9 @@ impl RpcHandler {
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.configure" => self.handle_mesh_configure(params).await,
// Server settings
"server.set-name" => self.handle_server_set_name(params).await,
// System monitoring
"system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await,
@@ -558,12 +597,14 @@ impl RpcHandler {
error: None,
},
Err(e) => {
error!("RPC error: {}", e);
error!("RPC error on {}: {}", rpc_req.method, e);
// Sanitize error messages: only return user-facing text, not internal details
let user_message = sanitize_error_message(&e.to_string());
RpcResponse {
result: None,
error: Some(RpcError {
code: -1,
message: e.to_string(),
message: user_message,
data: None,
}),
}

View File

@@ -226,8 +226,9 @@ impl RpcHandler {
let needs_archy_net = matches!(
package_id,
"bitcoin-knots" | "bitcoin" | "bitcoin-core"
| "lnd"
| "mempool" | "mempool-web" | "mempool-api" | "mempool-electrs" | "electrs" | "mysql-mempool" | "archy-mempool-db" | "archy-mempool-web"
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db"
| "btcpay-server" | "btcpayserver" | "archy-btcpay-db" | "archy-nbxplorer" | "nbxplorer"
| "fedimint" | "fedimint-gateway"
);
@@ -329,12 +330,18 @@ printtoconsole=1\n";
run_args.push(env);
}
// Security: Resource limits (from manifest)
let memory_limit = if package_id == "ollama" { "4g" } else { "2g" };
// Resource limits: per-app memory and CPU
let memory_limit = get_memory_limit(package_id);
let mem_arg = format!("--memory={}", memory_limit);
run_args.push(&mem_arg);
run_args.push("--cpus=2");
// Health check definitions
let health_args = get_health_check_args(package_id);
for arg in &health_args {
run_args.push(arg);
}
// Finally, the image
run_args.push(docker_image);
@@ -1289,6 +1296,148 @@ fn is_readonly_compatible(app_id: &str) -> bool {
)
}
/// Get container health check arguments for podman run.
/// Returns (health-cmd, interval, retries) args to append to run_args.
fn get_health_check_args(app_id: &str) -> Vec<String> {
let (cmd, interval, retries) = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
"bitcoin-cli -rpcuser=archipelago -rpcpassword=archipelago123 getblockchaininfo || exit 1",
"30s", "3",
),
"lnd" => (
"lncli getinfo || exit 1",
"30s", "3",
),
"btcpay-server" | "btcpayserver" => (
"curl -sf http://localhost:49392/ || exit 1",
"30s", "3",
),
"mempool-api" => (
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
"30s", "3",
),
"mempool" | "mempool-web" | "archy-mempool-web" => (
"curl -sf http://localhost:8080/ || exit 1",
"30s", "3",
),
"mempool-electrs" | "electrs" => (
"curl -sf http://localhost:50001/ || exit 1",
"60s", "3",
),
"nextcloud" => (
"curl -sf http://localhost:80/status.php || exit 1",
"30s", "3",
),
"homeassistant" | "home-assistant" => (
"curl -sf http://localhost:8123/api/ || exit 1",
"30s", "3",
),
"grafana" => (
"curl -sf http://localhost:3000/api/health || exit 1",
"30s", "3",
),
"jellyfin" => (
"curl -sf http://localhost:8096/health || exit 1",
"30s", "3",
),
"vaultwarden" => (
"curl -sf http://localhost:80/alive || exit 1",
"30s", "3",
),
"uptime-kuma" => (
"curl -sf http://localhost:3001/ || exit 1",
"30s", "3",
),
"filebrowser" => (
"curl -sf http://localhost:80/health || exit 1",
"30s", "3",
),
"searxng" => (
"curl -sf http://localhost:8080/ || exit 1",
"30s", "3",
),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s", "3",
),
"immich_server" | "immich" => (
"curl -sf http://localhost:2283/api/server/ping || exit 1",
"30s", "3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s", "3",
),
"portainer" => (
"curl -sf http://localhost:9000/api/status || exit 1",
"30s", "3",
),
"ollama" => (
"curl -sf http://localhost:11434/ || exit 1",
"30s", "3",
),
"fedimint" => (
"curl -sf http://localhost:8174/health || exit 1",
"60s", "3",
),
"nostr-rs-relay" | "nostr-relay" => (
"curl -sf http://localhost:8080/ || exit 1",
"30s", "3",
),
"nginx-proxy-manager" => (
"curl -sf http://localhost:81/api/ || exit 1",
"30s", "3",
),
_ => return vec![],
};
vec![
format!("--health-cmd={}", cmd),
format!("--health-interval={}", interval),
format!("--health-retries={}", retries),
"--health-start-period=60s".to_string(),
]
}
/// Get per-app memory limit.
fn get_memory_limit(app_id: &str) -> &'static str {
match app_id {
// Heavy apps
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "2g",
"onlyoffice" | "onlyoffice-documentserver" => "2g",
"ollama" => "4g",
// Medium apps
"lnd" => "512m",
"mempool-electrs" | "electrs" => "1g",
"nextcloud" => "1g",
"immich_server" | "immich" => "1g",
"btcpay-server" | "btcpayserver" => "1g",
"homeassistant" | "home-assistant" => "512m",
"fedimint" => "512m",
"fedimint-gateway" => "512m",
"photoprism" => "1g",
// Light apps
"mempool-api" => "512m",
"mempool" | "mempool-web" | "archy-mempool-web" => "256m",
"grafana" => "256m",
"jellyfin" => "1g",
"vaultwarden" => "256m",
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
"immich_postgres" | "penpot-postgres" => "256m",
"immich_redis" | "penpot-valkey" => "128m",
// Default
_ => "512m",
}
}
/// Get app-specific configuration
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
fn get_app_config(

View File

@@ -1,5 +1,5 @@
use super::RpcHandler;
use crate::{node_message, nostr_discovery, peers};
use crate::{federation, node_message, nostr_discovery, peers};
use crate::peers::KnownPeer;
use anyhow::Result;
@@ -61,15 +61,29 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
// Validate onion is a known peer to prevent SSRF to arbitrary Tor destinations
// Limit message size to 1MB to prevent DoS
if message.len() > 1_048_576 {
anyhow::bail!("Message too large (max 1MB)");
}
// Validate onion is a known peer or federated node to prevent SSRF
let known_peers = peers::load_peers(&self.config.data_dir).await?;
let is_known = known_peers.iter().any(|p| {
let is_known_peer = known_peers.iter().any(|p| {
p.onion == onion || p.onion == format!("{}.onion", onion)
|| format!("{}.onion", p.onion) == onion
});
if !is_known {
let is_known_fed = if !is_known_peer {
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
fed_nodes.iter().any(|n| {
n.onion == onion || n.onion == format!("{}.onion", onion)
|| format!("{}.onion", n.onion) == onion
})
} else {
false
};
if !is_known_peer && !is_known_fed {
return Err(anyhow::anyhow!(
"Onion address not in known peers list. Add the peer first."
"Onion address not in known peers or federation. Add the peer first."
));
}