feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements
Bitcoin UI: - Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts) - Make all asset paths relative for nginx proxy compatibility - Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely) - Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332) HTTPS mixed content fix: - Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS, iframe loads through nginx proxy instead of direct HTTP port - Prevents browser blocking HTTP iframes inside HTTPS pages - All Tailscale servers use HTTPS, this was breaking all app iframes Deploy & first-boot improvements: - first-boot-containers.sh auto-detects disk size for pruning vs txindex - first-boot-containers.sh checks fallback source path for UI containers - Added mempool-electrs to APP_PORTS mapping - ElectrumX container creation in first-boot - Podman doctor/fix/uptime skills added Also includes: session persistence, identity management, LND transactions, ElectrumX status UI, nostr-provider improvements, Web5 enhancements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
07e46dce56
commit
30164fd12a
@@ -1,15 +1,22 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
|
||||
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
||||
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
const MAX_CONCURRENT_SESSIONS: usize = 20;
|
||||
const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json";
|
||||
const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret";
|
||||
pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SessionType {
|
||||
@@ -38,13 +45,106 @@ struct Session {
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||||
persist_path: PathBuf,
|
||||
}
|
||||
|
||||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedSession {
|
||||
hash_hex: String,
|
||||
created_at: u64, // Unix timestamp
|
||||
last_activity: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
let persist_path = PathBuf::from(SESSIONS_FILE);
|
||||
let sessions = Self::load_from_disk(&persist_path);
|
||||
let count = sessions.len();
|
||||
if count > 0 {
|
||||
tracing::info!("Restored {} sessions from disk", count);
|
||||
}
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(sessions)),
|
||||
persist_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted sessions from disk (only Full sessions).
|
||||
fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> {
|
||||
let mut map = HashMap::new();
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return map,
|
||||
};
|
||||
let persisted: Vec<PersistedSession> = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse sessions file: {}", e);
|
||||
return map;
|
||||
}
|
||||
};
|
||||
let now_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
for p in persisted {
|
||||
// Skip expired sessions
|
||||
if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL {
|
||||
continue;
|
||||
}
|
||||
let hash = match hex::decode(&p.hash_hex) {
|
||||
Ok(h) if h.len() == 32 => {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&h);
|
||||
arr
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at);
|
||||
let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity);
|
||||
map.insert(hash, Session {
|
||||
created_at,
|
||||
last_activity,
|
||||
session_type: SessionType::Full,
|
||||
});
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Save all Full sessions to disk. Called after mutations.
|
||||
fn save_to_disk_sync(sessions: &HashMap<[u8; 32], Session>, path: &Path) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper for save — spawns to avoid blocking RPC.
|
||||
fn schedule_save(&self, sessions: &HashMap<[u8; 32], Session>) {
|
||||
let persisted: Vec<PersistedSession> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||||
.map(|(hash, s)| PersistedSession {
|
||||
hash_hex: hex::encode(hash),
|
||||
created_at: s.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
last_activity: s.last_activity.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
||||
})
|
||||
.collect();
|
||||
let path = self.persist_path.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||||
let _ = tokio::fs::write(path, json).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a full (authenticated) session. Returns the plaintext token.
|
||||
@@ -63,6 +163,9 @@ impl SessionStore {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
self.evict_if_over_limit(&mut sessions);
|
||||
sessions.insert(hash, session);
|
||||
// Sync save — must complete before returning the token to the client.
|
||||
// Async save risks losing the session if the process is killed (e.g., deploy restart).
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
token
|
||||
}
|
||||
|
||||
@@ -140,12 +243,15 @@ impl SessionStore {
|
||||
let now = SystemTime::now();
|
||||
session.created_at = now;
|
||||
session.last_activity = now;
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, token: &str) {
|
||||
let hash = hash_token(token);
|
||||
self.sessions.write().await.remove(&hash);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(&hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Invalidate all sessions except the one matching the given token.
|
||||
@@ -154,6 +260,7 @@ impl SessionStore {
|
||||
let keep_hash = hash_token(keep_token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.retain(|hash, _| *hash == keep_hash);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
}
|
||||
|
||||
/// Rotate a session: invalidate the old token and create a new one.
|
||||
@@ -175,6 +282,7 @@ impl SessionStore {
|
||||
session_type: SessionType::Full,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
new_token
|
||||
}
|
||||
|
||||
@@ -222,6 +330,78 @@ impl SessionStore {
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
// ── Remember-me token ──────────────────────────────────────────────
|
||||
// HMAC-signed token that survives backend restarts. Secret is on disk.
|
||||
// Format: "timestamp_hex:hmac_hex"
|
||||
|
||||
/// Create a remember-me token. Returns the cookie value.
|
||||
pub fn create_remember_token(&self) -> String {
|
||||
let secret = Self::load_or_create_remember_secret();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let ts_hex = hex::encode(now.to_be_bytes());
|
||||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
format!("{}:{}", ts_hex, sig)
|
||||
}
|
||||
|
||||
/// Validate a remember-me token. Returns true if valid and not expired.
|
||||
pub fn validate_remember_token(token: &str) -> bool {
|
||||
let secret = match std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
Ok(s) if s.len() == 32 => s,
|
||||
_ => return false,
|
||||
};
|
||||
let parts: Vec<&str> = token.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let ts_hex = parts[0];
|
||||
let sig_hex = parts[1];
|
||||
|
||||
// Verify HMAC
|
||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||||
let expected_sig = match hex::decode(sig_hex) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if mac.verify_slice(&expected_sig).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
let ts_bytes = match hex::decode(ts_hex) {
|
||||
Ok(b) if b.len() == 8 => {
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&b);
|
||||
u64::from_be_bytes(arr)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
||||
}
|
||||
|
||||
fn load_or_create_remember_secret() -> Vec<u8> {
|
||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
if secret.len() == 32 {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
let secret: [u8; 32] = rand::random();
|
||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
|
||||
secret.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> [u8; 32] {
|
||||
|
||||
Reference in New Issue
Block a user