Files
archy/core/archipelago/src/api/rpc/middleware.rs
T
archipelagoandClaude Opus 5 6ba0599639
Demo images / Build & push demo images (push) Failing after 2m13s
security: remove all infrastructure and internal process material from the repo
The repo is source code and guidelines only. Nothing about how Archipelago's
own fleet is run, or how the team works, stays in it.

Untracked (kept on disk, gitignored) — 250 files:
- .planning/ (199) and loop/ — internal development process
- fleet operations tooling that targets specific nodes: deploy-to-target,
  deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server,
  setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair,
  image-recipe/sync-from-live.sh
- image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of
  which are live-server workflow and fleet node inventories
- the Phase 10 on-node verification and evidence records, which cite .planning/
  as their evidence base

KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10
docs and then put back: it is cited as normative rationale from ten places in
the codebase, including core/clippy.toml, which bans rand::thread_rng and
points at it for the reason. That makes it a guideline, not an internal record.

Node names removed from source (48 occurrences across comments, manifests and
test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt,
zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the
date, which is what carried the meaning; the machine name did not.

Three of those were live test values rather than comments and were replaced
with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name.
An earlier pass substituted "a test node" into a hostname assertion, producing
an invalid hostname; caught and fixed as test-node.local.

Wipe mechanism: .local-only/manifest.txt inventories every local-only path and
.local-only/wipe.sh deletes them on one confirmation, refusing to touch
anything git still tracks. Both are themselves untracked, so the public repo
does not carry a map of internal filenames.

Verified: cargo check -p archipelago --all-features clean; archipelago-container
75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link
in tracked markdown resolves (0 broken).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:37:20 -04:00

347 lines
13 KiB
Rust

use crate::session::SessionStore;
use std::net::IpAddr;
/// Methods that do not require a valid session cookie.
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login",
"auth.login.totp",
"auth.login.backup",
"auth.isOnboardingComplete",
"auth.isSetup",
"auth.setup",
"auth.onboardingComplete",
"health",
// Server readiness check (Login.vue polls this before showing form)
"server.echo",
// Onboarding flow (before user has a session — DID creation, signing, backup)
"node.did",
"node.signChallenge",
"node.nostr-pubkey",
"node.createBackup",
"identity.create",
"identity.verify",
"identity.resolve-did",
// Seed management (onboarding — before user has a session)
"seed.generate",
"seed.verify",
"seed.restore",
"seed.save-encrypted",
// Onboarding restore (before user account exists)
"backup.restore-identity",
// Inter-node RPC: called by federated peers over Tor, no session cookies
"federation.peer-joined",
"federation.peer-address-changed",
"federation.peer-did-changed",
"federation.get-state",
// Fleet telemetry ingest: called by remote nodes posting reports
"telemetry.ingest",
];
/// Methods whose responses can be cached for a few seconds.
pub(super) const CACHEABLE_METHODS: &[&str] = &["system.stats", "federation.list-nodes"];
/// Sanitize error messages before returning to clients.
/// Keeps user-facing validation errors but strips internal system details.
pub(super) 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",
"requires",
"must be",
"cannot",
"Password",
"Session",
"Failed to pull",
"Failed to start",
"Failed to open channel",
"Failed to close channel",
"Failed to connect to peer",
// App-install dependency errors (package/dependencies.rs) — masking
// these left users retrying installs blind ("LND install failed" on a
// fresh node was really "Bitcoin Knots isn't running yet")
"LND requires",
"ElectrumX requires",
"BTCPay Server requires",
"Mempool requires",
"Container",
"Image",
// Wallet-actionable errors: masking "Insufficient balance: need 80
// sats, have 0 sats" behind "Operation failed. Check server logs."
// sent the operator to journalctl for a message that was written for
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// On-chain send/sweep refusals from LND ("Failed to send: your
// on-chain balance is too small or still unconfirmed to sweep…").
// Masking sent the operator to journalctl again (a test node
// sweep, 2026-08-06) — same lesson as the two above.
"Failed to send",
// A frontend newer than the daemon calls methods it doesn't have.
// Masked, this reads as "the feature is broken" instead of "this
// node needs its update" — hit live the moment the .126 LoRa panel
// was deployed ahead of its binary (2026-08-06).
"Unknown method",
// RNode RF settings validation (mesh::rnode_settings::validate) —
// every one names the offending field and its legal range, which is
// the entire point of validating before touching the radio.
"frequency ",
"bandwidth ",
"spreading factor ",
"coding rate ",
"tx power ",
"airtime_limit_short",
"airtime_limit_long",
"port must be an absolute",
"Invalid settings",
"Missing 'settings'",
// Mesh preconditions the operator can act on directly.
"Mesh service not running",
"No mesh device connected",
"Mesh listener not running",
"MeshCore radios have no remote reboot",
"Radio state read-back",
"The radio daemon did not answer",
"The radio did not acknowledge",
"RNode interface is disabled",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
// send, 2026-07-23).
"Payment failed",
"Invalid payment request",
"Missing 'payment_request'",
"Your Lightning node is still finishing",
"Bitcoin address",
"No router",
"No OpenWrt",
"No space left",
"Not enough flash",
"Not enough space",
"TollGate installation failed",
"No pre-built TollGate",
"opkg not found",
"apk update failed",
"No wireless interface",
"No wireless radio",
"WiFi radio enabled but",
"Missing required field",
// seed.reveal / auth flows — user-actionable, no internals to leak.
// Without these the sanitizer collapsed every reveal failure into
// "Operation failed. Check server logs." (which isn't even a crash).
"Incorrect",
"This node has no encrypted seed",
"No Lightning seed backup",
"Could not decrypt the saved Lightning seed",
"A 2FA code is required",
"2FA is enabled but",
"Could not decrypt the saved seed",
"Could not unlock 2FA",
"No mnemonic available",
"No pending seed generation",
"Submitted words",
"Already set up",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(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()
}
#[cfg(test)]
mod sanitize_tests {
use super::sanitize_error_message;
#[test]
fn seed_reveal_errors_pass_through() {
// Every user-actionable seed.reveal failure must reach the user —
// masking them as "Check server logs" sent a real user hunting a
// crash that never happened.
for msg in [
"Incorrect password",
"This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.",
"A 2FA code is required to reveal the recovery phrase",
"2FA is enabled but no TOTP data found",
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
"Could not unlock 2FA with this password",
"No mnemonic available. Generate or restore a seed first.",
"No Lightning seed backup exists on this node. It is captured automatically when the Lightning wallet is first created.",
"Could not decrypt the saved Lightning seed backup",
"Submitted words do not match generated seed",
"Already set up. Use auth.changePassword to change.",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (a test node, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
"Invalid payment request: must be a Lightning invoice (lnbc...)",
"Missing 'payment_request' parameter",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
sanitize_error_message("thread panicked at src/foo.rs:42"),
"Operation failed. Check server logs for details."
);
}
}
/// Derive a CSRF token from the session token via HMAC.
/// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state).
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret().await;
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
mac.update(format!("csrf:{}", session_token).as_bytes());
hex::encode(mac.finalize().into_bytes())
}
/// Extract a named cookie value from headers.
pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
let prefix = format!("{}=", name);
for value in headers.get_all("cookie") {
if let Ok(s) = value.to_str() {
for part in s.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix(&prefix) {
let val = val.trim();
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
}
None
}
/// The TCP peer address of the connection a request arrived on, injected
/// into request extensions by the server accept loop.
#[derive(Debug, Clone, Copy)]
pub struct PeerAddr(pub std::net::SocketAddr);
/// Extract the client IP for rate limiting.
///
/// `X-Real-IP`/`X-Forwarded-For` are only honored when the connection
/// itself comes from loopback — i.e. from our local nginx, which sets
/// `X-Real-IP $remote_addr`. On a direct connection (the FIPS peer
/// listener, or anything that isn't the local proxy) the headers are
/// client-supplied, so trusting them let an attacker rotate per-request
/// "IPs" and defeat the login rate limiter; there we use the socket
/// address instead.
pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
let socket_ip = parts.extensions.get::<PeerAddr>().map(|p| p.0.ip());
match socket_ip {
Some(ip) if ip.is_loopback() => forwarded_client_ip(&parts.headers).unwrap_or(ip),
Some(ip) => ip,
// No socket info recorded (shouldn't happen in the server path);
// fall back to the pre-extension behavior.
None => {
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
}
}
}
/// The proxy-reported client IP, if a forwarded header carries one.
fn forwarded_client_ip(headers: &hyper::HeaderMap) -> Option<IpAddr> {
headers
.get("x-real-ip")
.or_else(|| headers.get("x-forwarded-for"))
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<IpAddr>().ok())
}
#[cfg(test)]
mod client_ip_tests {
use super::*;
use std::net::SocketAddr;
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
let mut builder = hyper::Request::builder().uri("/rpc/v1");
if let Some(ip) = real_ip {
builder = builder.header("x-real-ip", ip);
}
let (mut parts, _) = builder.body(()).unwrap().into_parts();
if let Some(addr) = peer {
parts
.extensions
.insert(PeerAddr(addr.parse::<SocketAddr>().unwrap()));
}
parts
}
#[test]
fn loopback_connection_trusts_forwarded_header() {
// nginx on loopback forwards the real client IP — use it.
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
assert_eq!(
extract_client_ip(&parts),
"192.168.1.50".parse::<IpAddr>().unwrap()
);
}
#[test]
fn direct_connection_ignores_spoofed_header() {
// A direct (non-proxy) client rotating X-Real-IP per request must
// still bucket under its socket address.
let parts = parts_with(Some("203.0.113.9:9999"), Some("10.0.0.1"));
assert_eq!(
extract_client_ip(&parts),
"203.0.113.9".parse::<IpAddr>().unwrap()
);
}
#[test]
fn loopback_connection_without_header_uses_socket_ip() {
let parts = parts_with(Some("127.0.0.1:5000"), None);
assert_eq!(
extract_client_ip(&parts),
"127.0.0.1".parse::<IpAddr>().unwrap()
);
}
}