fc-server: session cleanup; conditional Secure cookie; kill_on_drop on NAR processes

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie7b9002bbdc0ad91bb041f89979881956a6a6964
This commit is contained in:
raf 2026-02-15 23:30:04 +03:00
commit 9bbc1754d9
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 70 additions and 17 deletions

View file

@ -63,6 +63,9 @@ async fn main() -> anyhow::Result<()> {
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
}; };
// Start background session cleanup to prevent memory leaks
state.spawn_session_cleanup();
let app = routes::router(state, &config.server); let app = routes::router(state, &config.server);
let bind_addr = format!("{host}:{port}"); let bind_addr = format!("{host}:{port}");

View file

@ -132,23 +132,20 @@ async fn narinfo(
let file_hash = nar_hash; let file_hash = nar_hash;
use std::fmt::Write;
let refs_joined = refs.join(" ");
let mut narinfo_text = format!( let mut narinfo_text = format!(
"StorePath: {store_path}\nURL: nar/{hash}.nar.zst\nCompression: \ "StorePath: {store_path}\nURL: nar/{hash}.nar.zst\nCompression: \
zstd\nFileHash: {file_hash}\nFileSize: {nar_size}\nNarHash: \ zstd\nFileHash: {file_hash}\nFileSize: {nar_size}\nNarHash: \
{nar_hash}\nNarSize: {nar_size}\nReferences: {refs}\n", {nar_hash}\nNarSize: {nar_size}\nReferences: {refs_joined}\n",
store_path = store_path,
hash = hash,
file_hash = file_hash,
nar_size = nar_size,
nar_hash = nar_hash,
refs = refs.join(" "),
); );
if let Some(deriver) = deriver { if let Some(deriver) = deriver {
narinfo_text.push_str(&format!("Deriver: {deriver}\n")); let _ = write!(narinfo_text, "Deriver: {deriver}\n");
} }
if let Some(ca) = ca { if let Some(ca) = ca {
narinfo_text.push_str(&format!("CA: {ca}\n")); let _ = write!(narinfo_text, "CA: {ca}\n");
} }
// Optionally sign if secret key is configured // Optionally sign if secret key is configured
@ -248,7 +245,10 @@ async fn serve_nar_zst(
_ => return Ok(StatusCode::NOT_FOUND.into_response()), _ => return Ok(StatusCode::NOT_FOUND.into_response()),
}; };
// Use two piped processes instead of sh -c to prevent command injection // Use two piped processes instead of sh -c to prevent command injection.
// nix uses std::process (sync) for piping stdout to zstd stdin.
// zstd uses tokio::process with kill_on_drop(true) to ensure cleanup
// if the client disconnects.
let mut nix_child = std::process::Command::new("nix") let mut nix_child = std::process::Command::new("nix")
.args(["store", "dump-path", &store_path]) .args(["store", "dump-path", &store_path])
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
@ -270,6 +270,7 @@ async fn serve_nar_zst(
.stdin(nix_stdout) .stdin(nix_stdout)
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn() .spawn()
.map_err(|_| { .map_err(|_| {
ApiError(fc_common::CiError::Build( ApiError(fc_common::CiError::Build(
@ -316,6 +317,7 @@ async fn serve_nar(
.args(["store", "dump-path", &store_path]) .args(["store", "dump-path", &store_path])
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn(); .spawn();
let mut child = match child { let mut child = match child {

View file

@ -25,7 +25,7 @@ use uuid::Uuid;
use crate::state::AppState; use crate::state::AppState;
// --- View models (pre-formatted for templates) --- // View models (pre-formatted for templates)
struct BuildView { struct BuildView {
id: Uuid, id: Uuid,
@ -237,7 +237,7 @@ fn auth_name(extensions: &Extensions) -> String {
.unwrap_or_default() .unwrap_or_default()
} }
// --- Templates --- // Askama templates
#[derive(Template)] #[derive(Template)]
#[template(path = "home.html")] #[template(path = "home.html")]
@ -430,7 +430,7 @@ struct MetricsTemplate {
auth_name: String, auth_name: String,
} }
// --- Handlers --- // Route handlers
async fn home( async fn home(
State(state): State<AppState>, State(state): State<AppState>,
@ -1201,7 +1201,7 @@ async fn admin_page(
) )
} }
// --- Setup Wizard --- // Setup wizard
async fn project_setup_page(extensions: Extensions) -> Html<String> { async fn project_setup_page(extensions: Extensions) -> Html<String> {
let tmpl = ProjectSetupTemplate { let tmpl = ProjectSetupTemplate {
@ -1215,7 +1215,7 @@ async fn project_setup_page(extensions: Extensions) -> Html<String> {
) )
} }
// --- Login / Logout --- // Login / Logout
async fn login_page() -> Html<String> { async fn login_page() -> Html<String> {
let tmpl = LoginTemplate { error: None }; let tmpl = LoginTemplate { error: None };
@ -1258,9 +1258,17 @@ async fn login_action(
created_at: std::time::Instant::now(), created_at: std::time::Instant::now(),
}); });
let secure_flag = if !state.config.server.cors_permissive
&& state.config.server.host != "127.0.0.1"
&& state.config.server.host != "localhost"
{
"; Secure"
} else {
""
};
let cookie = format!( let cookie = format!(
"fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \ "fc_user_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
Max-Age=86400" Max-Age=86400{secure_flag}"
); );
return ( return (
[(axum::http::header::SET_COOKIE, cookie)], [(axum::http::header::SET_COOKIE, cookie)],
@ -1314,9 +1322,17 @@ async fn login_action(
created_at: std::time::Instant::now(), created_at: std::time::Instant::now(),
}); });
let secure_flag = if !state.config.server.cors_permissive
&& state.config.server.host != "127.0.0.1"
&& state.config.server.host != "localhost"
{
"; Secure"
} else {
""
};
let cookie = format!( let cookie = format!(
"fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \ "fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
Max-Age=86400" Max-Age=86400{secure_flag}"
); );
( (
[(axum::http::header::SET_COOKIE, cookie)], [(axum::http::header::SET_COOKIE, cookie)],

View file

@ -7,6 +7,14 @@ use fc_common::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
/// Maximum session lifetime before automatic eviction (24 hours).
const SESSION_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(24 * 60 * 60);
/// How often the background cleanup task runs (every 5 minutes).
const SESSION_CLEANUP_INTERVAL: std::time::Duration =
std::time::Duration::from_secs(5 * 60);
/// Session data supporting both API key and user authentication /// Session data supporting both API key and user authentication
#[derive(Clone)] #[derive(Clone)]
pub struct SessionData { pub struct SessionData {
@ -69,3 +77,27 @@ pub struct AppState {
pub sessions: Arc<DashMap<String, SessionData>>, pub sessions: Arc<DashMap<String, SessionData>>,
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
} }
impl AppState {
/// Spawn a background task that periodically evicts expired sessions.
/// This prevents unbounded memory growth from the in-memory session store.
pub fn spawn_session_cleanup(&self) {
let sessions = self.sessions.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(SESSION_CLEANUP_INTERVAL).await;
let before = sessions.len();
sessions
.retain(|_, session| session.created_at.elapsed() < SESSION_MAX_AGE);
let evicted = before - sessions.len();
if evicted > 0 {
tracing::debug!(
evicted = evicted,
remaining = sessions.len(),
"Evicted expired sessions"
);
}
}
});
}
}