diff --git a/src/serve.rs b/src/serve.rsindex 0b06ef3..dc71347 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -28,7 +28,7 @@2828 use std::io::{Read, Write};2929 use std::net::{TcpListener, TcpStream};3030 use std::path::{Path, PathBuf};31-use std::time::Duration;31+use std::time::{Duration, Instant};3232 3333 use crate::inbox;3434 @@ -71,6 +71,21 @@ impl ServeOptions {7171 }7272 7373 /// Per-connection shared state (tuned caps + rate buckets + daily salt).74+/// Max concurrent connections. Each connection owns one thread and75+/// inbox answers sleep ~500ms by design, so an unbounded accept loop is76+/// a trivial thread-exhaustion DoS (f0rge #63). Over the cap: 503 now.77+pub const MAX_CONNS: usize = 64;78+79+/// Max concurrent `git upload-pack` runs (f0rge #64).80+pub const MAX_GIT_UPLOADS: usize = 4;81+82+/// Hard timeout for one `git upload-pack` run. Larger than the 10s83+/// metadata timeout: a clone legitimately streams the whole pack.84+pub const GIT_UPLOAD_TIMEOUT: Duration = Duration::from_secs(120);85+86+/// Total time allowed to read one request head (slowloris bound).87+pub const HEAD_DEADLINE: Duration = Duration::from_secs(10);88+7489 struct Shared {7590 www: PathBuf,7691 inbox_dir: PathBuf,@@ -85,6 +100,8 @@ struct Shared {85100 /// so `git clone http://host:port/repo.bundle` works (m6, issue #32).86101 /// `None` when no bundle was present at startup (static-only mode).87102 git_dir: Option<PathBuf>,103+ /// In-flight upload-pack runs (bounded by [`MAX_GIT_UPLOADS`]).104+ git_uploads: std::sync::Arc<std::sync::atomic::AtomicUsize>,88105 }89106 90107 /// Materialize `www/repo.bundle` into a bare repo for smart HTTP.@@ -225,12 +242,24 @@ pub fn run(opts: &ServeOptions) -> i32 {225242 max_bytes: opts.max_bytes,226243 quota: opts.quota,227244 git_dir: prepare_git_dir(&opts.www),245+ git_uploads: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),228246 });247+ let live = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));229248 for conn in listener.incoming() {230249 match conn {231- Ok(stream) => {250+ Ok(mut stream) => {251+ use std::sync::atomic::Ordering;252+ if live.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS {253+ live.fetch_sub(1, Ordering::Relaxed);254+ reject_overloaded(&mut stream);255+ continue;256+ }232257 let shared = std::sync::Arc::clone(&shared);233- std::thread::spawn(move || handle_conn(stream, &shared));258+ let slot = ConnSlot(std::sync::Arc::clone(&live));259+ std::thread::spawn(move || {260+ let _slot = slot;261+ handle_conn(stream, &shared);262+ });234263 }235264 Err(e) => {236265 eprintln!("hu13: accept: {e}");@@ -243,6 +272,41 @@ pub fn run(opts: &ServeOptions) -> i32 {243272 244273 // ---- HTTP plumbing (tiny, no deps) -------------------------------------245274 275+/// Decrements the live-connection counter on drop, whatever the thread276+/// does (panic included).277+struct ConnSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);278+279+impl Drop for ConnSlot {280+ fn drop(&mut self) {281+ self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);282+ }283+}284+285+/// Answer 503 without spawning a thread (the pool is already full).286+fn reject_overloaded(stream: &mut TcpStream) {287+ let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));288+ let body = b"overloaded289+";290+ let head = format!(291+ "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nRetry-After: 1\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",292+ body.len()293+ );294+ let _ = stream.write_all(head.as_bytes());295+ let _ = stream.write_all(body);296+ let _ = stream.flush();297+ // Drain the request bytes we never read: closing with unread data298+ // makes the kernel send RST, which can destroy the 503 the client299+ // has not read yet. Bounded by a short timeout, no thread.300+ let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));301+ let mut buf = [0u8; 4096];302+ for _ in 0..4 {303+ match stream.read(&mut buf) {304+ Ok(0) | Err(_) => break,305+ Ok(_) => {}306+ }307+ }308+}309+246310 struct Head {247311 method: String,248312 target: String,@@ -252,10 +316,11 @@ struct Head {252316 /// Read until `\r\n\r\n` (cap 16KiB of headers). `None` = stall/overflow/253317 /// EOF → caller drops the connection.254318 fn read_head(stream: &mut TcpStream) -> Option<(Head, Vec<u8>)> {319+ let start = Instant::now();255320 let mut buf: Vec<u8> = Vec::with_capacity(4096);256321 let mut chunk = [0u8; 4096];257322 loop {258- if buf.len() > 16 * 1024 {323+ if buf.len() > 16 * 1024 || start.elapsed() > HEAD_DEADLINE {259324 return None;260325 }261326 match stream.read(&mut chunk) {@@ -713,6 +778,56 @@ fn content_length(head: &Head) -> Option<usize> {713778 /// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo714779 /// materialized at startup (`Shared::git_dir`); anything else (wrong715780 /// method, missing bundle, git failure) is 404/405 with no echo.781+/// Bounded slot for one upload-pack run (f0rge #64).782+struct GitSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);783+784+impl GitSlot {785+ fn acquire(shared: &Shared) -> Option<Self> {786+ use std::sync::atomic::Ordering;787+ let n = shared.git_uploads.fetch_add(1, Ordering::Relaxed);788+ if n >= MAX_GIT_UPLOADS {789+ shared.git_uploads.fetch_sub(1, Ordering::Relaxed);790+ return None;791+ }792+ Some(Self(std::sync::Arc::clone(&shared.git_uploads)))793+ }794+}795+796+impl Drop for GitSlot {797+ fn drop(&mut self) {798+ self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);799+ }800+}801+802+/// Stream a file body with `Content-Length` (no full-pack buffering).803+fn respond_file(804+ stream: &mut TcpStream,805+ code: u16,806+ content_type: &str,807+ extra: &[(&str, &str)],808+ path: &Path,809+ len: u64,810+) {811+ let mut out = format!(812+ "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",813+ reason(code)814+ );815+ for (k, v) in extra {816+ out.push_str(k);817+ out.push_str(": ");818+ out.push_str(v);819+ out.push_str("\r\n");820+ }821+ out.push_str("\r\n");822+ if stream.write_all(out.as_bytes()).is_err() {823+ return;824+ }825+ if let Ok(mut f) = std::fs::File::open(path) {826+ let _ = std::io::copy(&mut f, stream);827+ }828+ let _ = stream.flush();829+}830+716831 fn handle_git_smart(717832 stream: &mut TcpStream,718833 head: &Head,@@ -792,37 +907,79 @@ fn handle_git_smart(792907 if req_body.len() != len {793908 return;794909 }910+ let Some(_slot) = GitSlot::acquire(shared) else {911+ static_error(stream, 503);912+ return;913+ };914+ // Spool the pack to a temp file and stream it out: a big repo's pack915+ // used to sit in RAM in full (`wait_with_output`), and there was no916+ // deadline at all (f0rge #64).917+ let tmp = std::env::temp_dir().join(format!(918+ "hu13-pack-{}-{}",919+ std::process::id(),920+ std::time::SystemTime::now()921+ .duration_since(std::time::UNIX_EPOCH)922+ .map(|d| d.as_nanos())923+ .unwrap_or(0)924+ ));925+ let pack_file = match std::fs::File::create(&tmp) {926+ Ok(f) => f,927+ Err(_) => {928+ static_error(stream, 503);929+ return;930+ }931+ };795932 let mut child = match std::process::Command::new("git")796933 .arg("upload-pack")797934 .arg("--stateless-rpc")798935 .arg(git_dir)799936 .stdin(std::process::Stdio::piped())800- .stdout(std::process::Stdio::piped())937+ .stdout(std::process::Stdio::from(pack_file))801938 .stderr(std::process::Stdio::null())802939 .spawn()803940 {804941 Ok(c) => c,805942 Err(_) => {943+ let _ = std::fs::remove_file(&tmp);806944 static_error(stream, 503);807945 return;808946 }809947 };810948 if let Some(mut stdin) = child.stdin.take() {811949 let _ = stdin.write_all(&req_body);950+ // Drop closes the pipe so upload-pack sees EOF.812951 }813- let out = child.wait_with_output();814- match out {815- Ok(o) if o.status.success() => {816- respond(817- stream,818- 200,819- "application/x-git-upload-pack-result",820- &[("Cache-Control", "no-cache")],821- &o.stdout,822- );952+ let start = Instant::now();953+ let status = loop {954+ match child.try_wait() {955+ Ok(Some(st)) => break Some(st),956+ Ok(None) => {957+ if start.elapsed() > GIT_UPLOAD_TIMEOUT {958+ let _ = child.kill();959+ let _ = child.wait();960+ break None;961+ }962+ std::thread::sleep(Duration::from_millis(10));963+ }964+ Err(_) => break None,823965 }824- _ => static_error(stream, 503),966+ };967+ let ok = status.is_some_and(|st| st.success());968+ if !ok {969+ let _ = std::fs::remove_file(&tmp);970+ static_error(stream, 503);971+ return;825972 }973+ let len = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);974+ respond_file(975+ stream,976+ 200,977+ "application/x-git-upload-pack-result",978+ &[("Cache-Control", "no-cache")],979+ &tmp,980+ len,981+ );982+ let _ = std::fs::remove_file(&tmp);826983 }827984 828985 #[allow(clippy::too_many_lines)]