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:
Dorian
2026-03-16 12:58:35 +00:00
co-authored by Claude Opus 4.6
parent 07e46dce56
commit 30164fd12a
49 changed files with 6180 additions and 495 deletions
+185 -5
View File
@@ -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] {