diff --git a/docs/architecture/web.md b/docs/architecture/web.md index 5d63ad8f..409a7d03 100644 --- a/docs/architecture/web.md +++ b/docs/architecture/web.md @@ -15,6 +15,13 @@ git 데이터도 터미널도 전혀 모르는 계층이며, 웹 표면이 하 정한다** — 같은 호스트의 다른 서버가 여기서 발급한 세션으로 인증되면 안 되므로 이름을 이 계층에 두지 않는다. 기본 바인딩은 loopback이며 **TLS는 없다** — 원격은 SSH 터널/리버스 프록시로 감싼다. 서버 활성 시 비밀번호가 없으면 랜덤 생성해 config에 기록하고(주석 보존) 시작 시 1회 출력한다. +- **세션 영속 (`common/sessions.rs`)**: 세션 토큰은 `~/.nightcrow/sessions` 파일에 영속화되어 + 데몬 재시작 후에도 살아남는다. 각 토큰은 24시간 TTL을 가지며(`SESSION_TTL`), 쿠키 `Max-Age`와 + 일치한다. 만료된 토큰은 `is_valid` 호출 시 지연 제거된다. **로그아웃은 서버측 취소** — + `revoke`가 메모리와 디스크 양쪽에서 토큰을 지우므로, 쿠키를 지우는 것만으로는 인증이 유지되지 + 않는다. 파일은 owner-only 권한(0o600)으로 생성되며(`platform::fs` seam), Windows에서는 + 대응 API가 없어 no-op이므로 운영자가 상태 디렉토리 위치로 통제한다. 파일이 손상되거나 읽을 수 + 없으면 빈 스토어로 시작한다 — 세션 파일 문제가 서버 시작을 막아서는 안 된다. - **스트리밍 응답 (`common/sse.rs`)**: `http::response`는 항상 `Content-Length`와 `Connection: close`를 실으므로 소켓을 열어 둔 채 이벤트를 덧붙일 경로가 없다. `SseStream`은 자기 헤드를 직접 쓰고 그 시점부터 연결을 소유한다. 매 쓰기마다 flush하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다) 쓰기 @@ -554,7 +561,11 @@ UI, `hooks/`는 UI·터미널·저장소 상태, `lib/`는 API 이외의 순수 입력·리사이즈·종료할 수 있다. 단일 공유 비밀번호에서는 일관되지만 pane 소유권 개념이 없다는 뜻이다. - **PTY는 연결이 끊겨도 회수되지 않는다**(재접속 시 세션 유지 목적). 저장소당 최대 8개가 프로세스 수명 동안 남는다. -- **세션에 절대 TTL이 없다.** 로그아웃은 서버측에서 취소하지만 방치된 세션은 프로세스 종료까지 유효하다. +- **세션 토큰은 디스크에 영속화된다.** `~/.nightcrow/sessions` 파일에 0o600 권한으로 + 저장되어 데몬 재시작 후에도 로그인이 유지된다. 24시간 TTL이 있지만, 비루프백 바인딩에서 + 토큰이 평문 HTTP로 전송되므로 네트워크 경로상 노출 창이 "프로세스 종료까지"에서 + "TTL 만료까지"로 넓어진다. 원격 접속은 SSH 터널이나 TLS 프록시로 감싸야 한다. + Windows에서는 파일 권한이 no-op이므로 상태 디렉토리 위치로 통제한다. - **`Secure` 쿠키 플래그 없음.** loopback 기본값에서는 맞지만 `bind`를 바꾸면 평문 HTTP로 토큰이 나간다. ← [Architecture index](../architecture.md) diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 8e2d2ca4..34e279b0 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -212,7 +212,10 @@ the same set. one is generated and written back into your config (so it survives restarts and stays readable) and printed once at startup. To avoid a plaintext password on disk, set `hashed_password` to an Argon2 PHC string instead — it takes -precedence. Login is rate-limited and grants a session cookie. +precedence. Login is rate-limited and grants a session cookie. Sessions survive +a daemon restart: tokens are persisted to `~/.nightcrow/sessions` with a 24-hour +TTL and owner-only file permissions. Logout revokes the token server-side, so +clearing the cookie alone is not enough to invalidate a session. > **Security.** The viewer serves repository contents *and* interactive > terminals, so an authenticated session is equivalent to shell access. It binds diff --git a/src/platform/fs.rs b/src/platform/fs.rs new file mode 100644 index 00000000..a7236a1a --- /dev/null +++ b/src/platform/fs.rs @@ -0,0 +1,52 @@ +//! Filesystem permissions seam. Unix-only APIs like `PermissionsExt` are +//! kept here so call sites stay platform-agnostic. Where Windows has no +//! equivalent, the no-op is documented inline. + +use std::path::Path; + +/// Restrict a file to owner-only access. On Unix this sets mode 0o600. On +/// Windows there is no portable equivalent of `chmod 600` — the file inherits +/// its ACL from the parent directory and `std::fs` exposes no per-file +/// permission setter — so the call is a documented no-op. Operators who need +/// the guarantee on Windows should place the state directory in an +/// ACL-restricted location. +pub fn set_owner_only(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(err) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + tracing::warn!(%err, ?path, "could not set owner-only permissions on session file"); + } + } + #[cfg(not(unix))] + { + // Windows: no portable per-file permission API. See doc comment above. + let _ = path; + } +} + +/// Write `data` to `path` atomically: write to a sibling temp file, then +/// rename over the target. The temp file is in the same directory so the +/// rename is atomic on the same filesystem. Permissions are restricted to +/// owner-only before the rename. +pub fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> { + let dir = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!( + ".{}.tmp", + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "session".into()) + )); + { + use std::io::Write; + let mut f = std::fs::File::create(&tmp)?; + f.write_all(data)?; + f.sync_all()?; + } + set_owner_only(&tmp); + std::fs::rename(&tmp, path)?; + Ok(()) +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 49f546e7..e2c56893 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,6 +1,7 @@ //! Operating-system-adjacent services shared by application layers. pub(crate) mod console; +pub(crate) mod fs; pub(crate) mod logging; pub(crate) mod paths; pub(crate) mod signals; diff --git a/src/web/common/auth.rs b/src/web/common/auth.rs index 81e9f4fe..e6685bdd 100644 --- a/src/web/common/auth.rs +++ b/src/web/common/auth.rs @@ -1,11 +1,11 @@ //! Authentication for nightcrow's web servers: Argon2 password verification -//! (matching code-server's scheme), opaque session tokens, and login rate -//! limiting. The cookie name belongs to each server, not here. +//! (matching code-server's scheme) and login rate limiting. Session tokens +//! live in `sessions.rs`. The cookie name belongs to each server, not here. use anyhow::{Result, anyhow}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use std::collections::{HashSet, VecDeque}; +use std::collections::VecDeque; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -51,65 +51,6 @@ impl Auth { } } -/// A set of live session tokens. Tokens are opaque 256-bit random strings and -/// stay valid until the process exits (a fresh launch invalidates all sessions, -/// which is the desired behaviour for a dev tool). -pub struct SessionStore { - tokens: Mutex>, -} - -impl SessionStore { - pub fn new() -> Self { - Self { - tokens: Mutex::new(HashSet::new()), - } - } - - /// Mint a new session token and remember it. - pub fn issue(&self) -> Result { - let mut bytes = [0u8; 32]; - getrandom::fill(&mut bytes) - .map_err(|e| anyhow!("OS RNG unavailable for session token: {e}"))?; - let token = hex(&bytes); - self.tokens - .lock() - .expect("session store mutex poisoned") - .insert(token.clone()); - Ok(token) - } - - /// Invalidate a token server-side. Clearing the cookie alone leaves a - /// leaked token usable until the process exits, which makes logout a - /// suggestion rather than a revocation. - pub fn revoke(&self, token: &str) { - self.tokens - .lock() - .expect("session store mutex poisoned") - .remove(token); - } - - pub fn is_valid(&self, token: &str) -> bool { - self.tokens - .lock() - .expect("session store mutex poisoned") - .contains(token) - } -} - -impl Default for SessionStore { - fn default() -> Self { - Self::new() - } -} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - /// Login rate limiter mirroring code-server: at most 2 attempts per minute and /// 14 per hour (2/min baseline plus 12 additional/hour). Shared across all /// clients since there is a single password. @@ -182,34 +123,6 @@ mod tests { assert!(Auth::from_hashed("not-a-phc-string").is_err()); } - #[test] - fn session_tokens_are_unique_and_validate() { - let store = SessionStore::new(); - let a = store.issue().unwrap(); - let b = store.issue().unwrap(); - assert_ne!(a, b); - assert_eq!(a.len(), 64, "32 random bytes hex-encode to 64 chars"); - assert!(store.is_valid(&a)); - assert!(store.is_valid(&b)); - assert!(!store.is_valid("unknown")); - } - - #[test] - fn a_revoked_session_stops_validating() { - let store = SessionStore::new(); - let token = store.issue().unwrap(); - assert!(store.is_valid(&token)); - - store.revoke(&token); - - assert!( - !store.is_valid(&token), - "a leaked token must stop working at logout, not at process exit" - ); - // Revoking an unknown token is a no-op, not a panic. - store.revoke("never-issued"); - } - #[test] fn rate_limiter_allows_two_per_minute_then_blocks() { let limiter = RateLimiter::new(); diff --git a/src/web/common/mod.rs b/src/web/common/mod.rs index f3fe08c3..be58247a 100644 --- a/src/web/common/mod.rs +++ b/src/web/common/mod.rs @@ -7,4 +7,5 @@ pub mod auth; pub mod conn; pub mod http; +pub mod sessions; pub mod sse; diff --git a/src/web/common/sessions.rs b/src/web/common/sessions.rs new file mode 100644 index 00000000..be70b42c --- /dev/null +++ b/src/web/common/sessions.rs @@ -0,0 +1,249 @@ +//! Persistent session token store. Tokens are opaque 256-bit random strings +//! backed by a file so they survive daemon restarts. Each token carries an +//! expiry (TTL) so a restarted server does not accept stale tokens forever. +//! +//! Logout revokes a token server-side — clearing the cookie alone is not +//! enough because a leaked token would remain usable until expiry. Revocation +//! removes the token from both memory and the on-disk store. +//! +//! The file is written with owner-only permissions (0o600 on Unix); see +//! `platform::fs`. On Windows the permission call is a no-op (documented +//! there), so operators should place the state directory in a restricted +//! location. +//! +//! When `store_path` is `None` the store is in-memory only, matching the old +//! behaviour for tests and transient runs. + +use crate::platform; +use anyhow::{Context, Result, anyhow}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Session lifetime: 24 hours, matching the cookie Max-Age. +pub const SESSION_TTL: Duration = Duration::from_secs(86400); + +pub struct SessionStore { + tokens: Mutex>, + store_path: Option, +} + +impl SessionStore { + /// In-memory only (no disk persistence). Used by tests and transient + /// runs where restart-survival is not needed. + pub fn new() -> Self { + Self { + tokens: Mutex::new(HashMap::new()), + store_path: None, + } + } + + /// Load tokens from `path`, discarding any that are already expired. If + /// the file is missing or unreadable, start empty — a corrupt session + /// file should not prevent the server from starting. + pub fn load(path: PathBuf) -> Self { + let mut tokens = HashMap::new(); + if let Ok(data) = std::fs::read_to_string(&path) { + let now = SystemTime::now(); + for line in data.lines() { + if let Some((token, expiry_str)) = line.split_once('\t') + && let Ok(secs) = expiry_str.trim().parse::() + { + let expiry = UNIX_EPOCH + Duration::from_secs(secs); + if expiry > now { + tokens.insert(token.to_string(), expiry); + } + } + } + } + Self { + tokens: Mutex::new(tokens), + store_path: Some(path), + } + } + + /// Mint a new session token and remember it. + pub fn issue(&self) -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow!("OS RNG unavailable for session token: {e}"))?; + let token = hex(&bytes); + let expiry = SystemTime::now() + SESSION_TTL; + { + let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); + tokens.insert(token.clone(), expiry); + } + self.persist(); + Ok(token) + } + + /// Invalidate a token server-side. Clearing the cookie alone leaves a + /// leaked token usable until expiry, which makes logout a suggestion + /// rather than a revocation. + pub fn revoke(&self, token: &str) { + { + let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); + tokens.remove(token); + } + self.persist(); + } + + pub fn is_valid(&self, token: &str) -> bool { + let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); + let now = SystemTime::now(); + match tokens.get(token) { + Some(expiry) if *expiry > now => true, + Some(_) => { + // Expired — remove lazily. + tokens.remove(token); + drop(tokens); + self.persist(); + false + } + None => false, + } + } + + /// Write the current token set to disk. Failures are logged but not + /// propagated: a session that fails to persist is still valid in memory + /// — the user just loses restart-survival, not access. + fn persist(&self) { + let Some(path) = &self.store_path else { return }; + let data = self.serialize(); + if let Err(err) = platform::fs::write_atomic(path, data.as_bytes()) { + tracing::warn!(%err, ?path, "could not persist session store"); + } + } + + fn serialize(&self) -> String { + let tokens = self.tokens.lock().expect("session store mutex poisoned"); + let mut out = String::new(); + for (token, expiry) in tokens.iter() { + let secs = expiry + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push_str(token); + out.push('\t'); + out.push_str(&secs.to_string()); + out.push('\n'); + } + out + } +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Resolve the session store path relative to the nightcrow state directory. +pub fn session_store_path() -> Result { + let anchor = platform::paths::state_dir_anchor(); + let dir = Path::new(&anchor).join(".nightcrow"); + std::fs::create_dir_all(&dir).context("creating nightcrow state directory for sessions")?; + Ok(dir.join("sessions")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn tmp_path(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("nightcrow-test-{name}")); + let _ = std::fs::remove_file(&dir); + dir + } + + #[test] + fn in_memory_store_issues_unique_valid_tokens() { + let store = SessionStore::new(); + let a = store.issue().unwrap(); + let b = store.issue().unwrap(); + assert_ne!(a, b); + assert_eq!(a.len(), 64); + assert!(store.is_valid(&a)); + assert!(store.is_valid(&b)); + assert!(!store.is_valid("unknown")); + } + + #[test] + fn revoke_stops_validating() { + let store = SessionStore::new(); + let token = store.issue().unwrap(); + assert!(store.is_valid(&token)); + store.revoke(&token); + assert!(!store.is_valid(&token)); + // Revoking an unknown token is a no-op. + store.revoke("never-issued"); + } + + #[test] + fn persisted_tokens_survive_reload() { + let path = tmp_path("persist-survive"); + { + let store = SessionStore::load(path.clone()); + let token = store.issue().unwrap(); + assert!(store.is_valid(&token)); + } + // A new store loading the same file should recognise the token. + let store = SessionStore::load(path.clone()); + let data = std::fs::read_to_string(&path).unwrap(); + assert!(!data.is_empty()); + // Extract the token from the file to check it validates. + let token = data.split('\t').next().unwrap().trim(); + assert!(store.is_valid(token)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn revoked_token_is_absent_after_reload() { + let path = tmp_path("persist-revoke"); + let keep; + let kill; + { + let store = SessionStore::load(path.clone()); + keep = store.issue().unwrap(); + kill = store.issue().unwrap(); + store.revoke(&kill); + } + let store = SessionStore::load(path.clone()); + assert!(store.is_valid(&keep)); + assert!(!store.is_valid(&kill)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn expired_tokens_are_not_loaded() { + let path = tmp_path("persist-expired"); + // Write a token that expired in the past. + let past = SystemTime::now() - Duration::from_secs(3600); + let past_secs = past.duration_since(UNIX_EPOCH).unwrap().as_secs(); + let data = format!("deadbeef\t{past_secs}\n"); + std::fs::write(&path, data).unwrap(); + let store = SessionStore::load(path.clone()); + assert!(!store.is_valid("deadbeef")); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn corrupt_file_starts_empty() { + let path = tmp_path("persist-corrupt"); + std::fs::write(&path, "garbage no tab\n???\n").unwrap(); + let store = SessionStore::load(path.clone()); + let token = store.issue().unwrap(); + assert!(store.is_valid(&token)); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index ceaa7b58..0578285e 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -13,7 +13,9 @@ mod routes; use crate::session::prefs::PrefsStore; use crate::session::{SessionOptions, SessionState}; -use crate::web::common::auth::{Auth, RateLimiter, SessionStore}; +use crate::web::common::auth::{Auth, RateLimiter}; +use crate::web::common::sessions; +use crate::web::common::sessions::SessionStore; use anyhow::{Context, Result}; use std::net::{IpAddr, SocketAddr, TcpListener}; use std::sync::Arc; @@ -51,6 +53,7 @@ pub struct ViewerOptions { pub bind: IpAddr, pub port: u16, pub auth: Auth, + pub sessions: SessionStore, pub hot: crate::config::AgentIndicatorConfig, pub session: SessionOptions, } @@ -77,7 +80,7 @@ impl ViewerState { Self { bound_loopback: options.bind.is_loopback(), auth: options.auth, - sessions: SessionStore::new(), + sessions: options.sessions, limiter: RateLimiter::new(), connections: Arc::new(AtomicUsize::new(0)), hot: options.hot, @@ -105,11 +108,18 @@ impl ViewerServer { viewer.bind ) })?; + let session_store = sessions::session_store_path() + .map(SessionStore::load) + .unwrap_or_else(|err| { + tracing::warn!(%err, "could not open session store; starting in-memory"); + SessionStore::new() + }); Self::start_with_plugins( ViewerOptions { bind, port: viewer.port, auth, + sessions: session_store, hot: launch.agent_indicator.clone(), session: SessionOptions { repos: launch.paths.to_vec(), diff --git a/src/web/viewer/server/tests/mod.rs b/src/web/viewer/server/tests/mod.rs index eb21106f..42d8fc85 100644 --- a/src/web/viewer/server/tests/mod.rs +++ b/src/web/viewer/server/tests/mod.rs @@ -14,6 +14,7 @@ use super::{VIEWER_SESSION_COOKIE, ViewerOptions, ViewerServer}; use crate::session::prefs::PrefsStore; use crate::test_util::{make_repo, run_git}; use crate::web::common::auth::Auth; +use crate::web::common::sessions::SessionStore; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::time::Duration; @@ -43,6 +44,7 @@ pub(super) fn server_with( bind: "127.0.0.1".parse().unwrap(), port: 0, auth: Auth::from_plaintext("swordfish").unwrap(), + sessions: SessionStore::new(), hot, session: crate::session::SessionOptions { repos: paths.to_vec(),