Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/architecture/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다) 쓰기
Expand Down Expand Up @@ -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)
5 changes: 4 additions & 1 deletion docs/web-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/platform/fs.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
1 change: 1 addition & 0 deletions src/platform/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
93 changes: 3 additions & 90 deletions src/web/common/auth.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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<HashSet<String>>,
}

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<String> {
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.
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/web/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
pub mod auth;
pub mod conn;
pub mod http;
pub mod sessions;
pub mod sse;
Loading