Skip to main content

majordomus_cli/http/
server.rs

1//! The socket: bind, read requests, hand them to the router, write responses. Loopback by
2//! default; the bound address is reported so that a caller who asked for port 0, or whose
3//! port was taken, learns the one in use. A bound socket is accepted by a few worker
4//! threads, and each request is answered on a thread of its own up to a bound, so a slow
5//! request queues nothing behind it — not even the probe a client sends before it would
6//! take the lease over — and the owner's stdio session never waits on HTTP. Stopping is cooperative: every
7//! worker is unblocked and joined, and an in-flight response is finished first. A stopped
8//! server refuses the next connection: `stop` returns after the listening socket is closed. A connection
9//! that reads no request within the declared deadline is closed ([`super::deadline`]), and a
10//! request that finds every handler busy is refused rather than answered on a worker.
11
12use std::io::Read;
13use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
14use std::sync::Arc;
15use std::thread::JoinHandle;
16
17use tiny_http::{Header, Response as HttpResponse, Server};
18
19use crate::error::{Error, Result};
20
21use super::deadline::Watch;
22use super::Router;
23
24/// The largest request body accepted.
25pub const MAX_BODY_BYTES: usize = 1024 * 1024;
26
27/// How many threads accept requests on one socket. They only accept: each request is answered
28/// on a thread of its own (up to [`MAX_HANDLERS`]), so a worker is free again at once and a
29/// lease probe is answered while tool calls run (I2126).
30pub const WORKERS: usize = 4;
31
32/// How many requests are answered at once. A bound, so that a flood costs a fixed number of
33/// threads; far above what clients of one checkout send, so that a few slow tool calls never
34/// hold the probe up. Past it a request is answered 503, except the probe (`GET /`), which a
35/// worker answers itself: it is cheap, and it is what decides whether this server keeps its
36/// lease (I2156).
37pub const MAX_HANDLERS: usize = 64;
38
39/// Requests being answered right now, on threads of their own.
40static HANDLERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
41
42/// When this process last answered a request, in milliseconds since this process started;
43/// zero before the first. Read by the idle timer, so that a person reading the Cockpit or a
44/// client calling the REST API keeps the server they are using (I2131).
45static LAST_REQUEST_MS: AtomicU64 = AtomicU64::new(0);
46
47/// Live `/events` channels open right now: a person watching an execution is using the server
48/// for as long as the socket stays open, whether or not they send anything.
49static OPEN_CHANNELS: AtomicUsize = AtomicUsize::new(0);
50
51fn epoch() -> std::time::Instant {
52    static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
53    *START.get_or_init(std::time::Instant::now)
54}
55
56fn note_request() {
57    let ms = epoch().elapsed().as_millis() as u64;
58    LAST_REQUEST_MS.store(ms.max(1), Ordering::Relaxed);
59}
60
61/// Whether this process was used over HTTP within the last `window`: a request answered in
62/// it, or a live channel open now. The idle timer asks this beside the MCP sessions.
63///
64/// ```
65/// use std::time::Duration;
66/// use majordomus_cli::http::server::used_within;
67/// // a process that has answered nothing over HTTP and holds no channel has not been used
68/// assert!(!used_within(Duration::from_secs(60)));
69/// ```
70pub fn used_within(window: std::time::Duration) -> bool {
71    if OPEN_CHANNELS.load(Ordering::Relaxed) > 0 {
72        return true;
73    }
74    let last = LAST_REQUEST_MS.load(Ordering::Relaxed);
75    last != 0 && epoch().elapsed().as_millis() as u64 <= last + window.as_millis() as u64
76}
77
78/// A socket that is bound and not yet served.
79pub struct Bound {
80    server: Arc<Server>,
81    address: String,
82}
83
84/// Bind `host:port`; `0` picks a free port. The error names the address when it fails.
85pub fn bind(host: &str, port: u16) -> Result<Bound> {
86    let server = Arc::new(Server::http((host, port)).map_err(|e| Error::Http {
87        reason: format!("cannot bind {host}:{port}: {e}"),
88    })?);
89    let address = server
90        .server_addr()
91        .to_ip()
92        .map(|a| a.to_string())
93        .unwrap_or_else(|| format!("{host}:{port}"));
94    if address
95        .parse::<std::net::SocketAddr>()
96        .is_ok_and(|a| !a.ip().is_loopback())
97    {
98        tracing::warn!(
99            address = %address,
100            "bound to {address}, which is not a loopback address: every host that can reach this interface can read this repository's AI layer, its diagnostics and its peers, unencrypted; it can change nothing but through a signed mesh message (ADR 0126); bind 127.0.0.1 unless that is intended"
101        );
102    }
103    Ok(Bound { server, address })
104}
105
106/// Bind the address a deployment object declared. The accidental-bind warning does not
107/// fire here and is not suppressed either: it asks "did you mean this", and `source` is the
108/// answer — the canonical object that said so, named in the log so the operator can read
109/// which file decided the address. A hosted process that bound loopback would be
110/// unreachable inside its own machine, which is why this path exists at all.
111pub fn bind_declared(host: &str, port: u16, source: &str) -> Result<Bound> {
112    let server = Arc::new(Server::http((host, port)).map_err(|e| Error::Http {
113        reason: format!("cannot bind {host}:{port}: {e}"),
114    })?);
115    let address = server
116        .server_addr()
117        .to_ip()
118        .map(|a| a.to_string())
119        .unwrap_or_else(|| format!("{host}:{port}"));
120    tracing::info!(
121        address = %address,
122        source = %source,
123        "bound {address}, the address {source} declares; this repository's AI layer is readable by every host that can reach this interface, which is what a deployment means"
124    );
125    Ok(Bound { server, address })
126}
127
128/// Bind `host:port`, and when that port is taken bind a free one instead, saying so on
129/// stderr. For a server whose port is a convenience rather than a contract.
130pub fn bind_or_fallback(host: &str, port: u16) -> Result<Bound> {
131    match bind(host, port) {
132        Ok(b) => Ok(b),
133        Err(e) if port != 0 => {
134            tracing::warn!("{e}; taking a free port instead");
135            bind(host, 0)
136        }
137        Err(e) => Err(e),
138    }
139}
140
141impl Bound {
142    /// `host:port` as bound.
143    pub fn address(&self) -> &str {
144        &self.address
145    }
146
147    /// `http://host:port`.
148    pub fn url(&self) -> String {
149        format!("http://{}", self.address)
150    }
151
152    /// Start [`WORKERS`] threads answering requests through `router`, and the sweeper that
153    /// holds their connections to the declared read deadline.
154    pub fn start(self, router: Router) -> Running {
155        let stopping = Arc::new(AtomicBool::new(false));
156        let local = self
157            .address
158            .parse()
159            .unwrap_or(std::net::SocketAddr::from(([0, 0, 0, 0], 0)));
160        let watch = Arc::new(Watch::new(local, crate::lease::timings().read_deadline));
161        let mut threads: Vec<JoinHandle<()>> = (0..WORKERS)
162            .map(|n| {
163                let server = Arc::clone(&self.server);
164                let router = router.clone();
165                let stopping = Arc::clone(&stopping);
166                let watch = Arc::clone(&watch);
167                std::thread::Builder::new()
168                    .name(format!("http-{n}"))
169                    .spawn(move || worker(&server, &router, &watch, &stopping))
170                    .expect("spawn an http worker")
171            })
172            .collect();
173        let sweeper = {
174            let stopping = Arc::clone(&stopping);
175            std::thread::Builder::new()
176                .name("http-deadline".into())
177                .spawn(move || watch.run(&stopping))
178                .expect("spawn the http deadline sweeper")
179        };
180        threads.push(sweeper);
181        Running {
182            server: self.server,
183            address: self.address,
184            stopping,
185            threads,
186        }
187    }
188}
189
190/// A served socket.
191pub struct Running {
192    server: Arc<Server>,
193    address: String,
194    stopping: Arc<AtomicBool>,
195    threads: Vec<JoinHandle<()>>,
196}
197
198impl Running {
199    /// `host:port` as bound.
200    pub fn address(&self) -> &str {
201        &self.address
202    }
203
204    /// `http://host:port`.
205    pub fn url(&self) -> String {
206        format!("http://{}", self.address)
207    }
208
209    /// Unblock every worker and wait for it; an in-flight response is finished first. When
210    /// this returns the address refuses a connection: the next caller learns that nothing was
211    /// sent, not that an answer was lost.
212    pub fn stop(self) {
213        self.stopping.store(true, Ordering::SeqCst);
214        // one more than there are workers is harmless; the sweeper reads `stopping` itself
215        for _ in &self.threads {
216            self.server.unblock();
217        }
218        for t in self.threads {
219            let _ = t.join();
220        }
221        // the requests already handed off are answered before the process goes on to stop,
222        // within a bound: a handler stuck forever does not hold the stop forever
223        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
224        while HANDLERS.load(Ordering::SeqCst) > 0 && std::time::Instant::now() < deadline {
225            std::thread::sleep(std::time::Duration::from_millis(20));
226        }
227        drop(self.server);
228        closed(&self.address);
229        tracing::info!(address = %self.address, "http stopped");
230    }
231}
232
233/// How long [`Running::stop`] waits for the listening socket to close.
234const CLOSE_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
235
236/// Wait until `address` refuses a connection, within [`CLOSE_GRACE`].
237///
238/// Dropping the server only asks its accept thread to end: the listening socket is closed by
239/// that thread, some time after the drop returns. Until then the kernel still completes a
240/// connection nobody will read, and the caller is reset after it has sent its request, so it
241/// cannot tell whether the request ran. A stopped server is one that refuses, and `stop`
242/// returns only when this one does. Another process bound to the same port keeps answering;
243/// the bound is what ends the wait then.
244fn closed(address: &str) {
245    let Ok(address) = address.parse::<std::net::SocketAddr>() else {
246        return;
247    };
248    let deadline = std::time::Instant::now() + CLOSE_GRACE;
249    let attempt = std::time::Duration::from_millis(100);
250    while std::time::Instant::now() < deadline {
251        if std::net::TcpStream::connect_timeout(&address, attempt).is_err() {
252            return;
253        }
254        std::thread::sleep(std::time::Duration::from_millis(5));
255    }
256    tracing::warn!(%address, "the listening socket still accepts after the server stopped");
257}
258
259fn worker(server: &Server, router: &Router, watch: &Arc<Watch>, stopping: &AtomicBool) {
260    loop {
261        match server.recv() {
262            Ok(request) => hand_off(router, watch, request),
263            Err(_) if stopping.load(Ordering::SeqCst) => break,
264            Err(e) => tracing::warn!("accepting a connection failed: {e}"),
265        }
266    }
267}
268
269/// Answer `request` on a thread of its own while fewer than [`MAX_HANDLERS`] are, so that the
270/// worker accepts the next request at once. Past the bound the probe is answered here and
271/// anything else is refused 503: a worker that read a slow body itself would be held by it.
272fn hand_off(router: &Router, watch: &Arc<Watch>, request: tiny_http::Request) {
273    if HANDLERS.fetch_add(1, Ordering::SeqCst) >= MAX_HANDLERS {
274        HANDLERS.fetch_sub(1, Ordering::SeqCst);
275        let probe = matches!(
276            request.method(),
277            tiny_http::Method::Get | tiny_http::Method::Head
278        ) && request.url() == "/";
279        if probe {
280            answer(router, watch, request);
281        } else {
282            super::deadline::refused();
283            watch.stop_reading(request.remote_addr());
284            let busy = super::router::Response::error(
285                503,
286                "busy",
287                &format!("{MAX_HANDLERS} requests are being answered; ask again"),
288            )
289            .with_header("Retry-After", "1");
290            respond(request, busy, false);
291        }
292        return;
293    }
294    let router = router.clone();
295    let watch = Arc::clone(watch);
296    let spawned = std::thread::Builder::new()
297        .name("http-request".into())
298        .spawn(move || {
299            answer(&router, &watch, request);
300            HANDLERS.fetch_sub(1, Ordering::SeqCst);
301        });
302    released_unless_spawned(spawned, &HANDLERS);
303}
304
305/// A request whose thread could not be spawned went with the closure, and the client sees its
306/// connection close: give back the slot it took in `handlers`, and say so.
307fn released_unless_spawned(spawned: std::io::Result<JoinHandle<()>>, handlers: &AtomicUsize) {
308    if let Err(e) = spawned {
309        handlers.fetch_sub(1, Ordering::SeqCst);
310        tracing::warn!("a request could not be given a thread: {e}");
311    }
312}
313
314fn answer(router: &Router, watch: &Watch, request: tiny_http::Request) {
315    note_request();
316    let peer = request.remote_addr().copied();
317    watch.reading(peer.as_ref());
318    if let Some(request) = upgrade(router, watch, request) {
319        answer_http(router, watch, request);
320    }
321    watch.answered(peer.as_ref());
322}
323
324/// Hand the socket to the live channel when the request asks for it, on a thread of its
325/// own so the worker goes back to answering requests.
326///
327/// Returns the request when it was not an upgrade, and nothing when the socket has been
328/// given away. This is the one thing the router cannot do for itself: `tiny_http` yields
329/// the stream only by consuming the request.
330fn upgrade(
331    router: &Router,
332    watch: &Watch,
333    request: tiny_http::Request,
334) -> Option<tiny_http::Request> {
335    let headers: Vec<(String, String)> = request
336        .headers()
337        .iter()
338        .map(|h| (h.field.as_str().to_string(), h.value.as_str().to_string()))
339        .collect();
340    let probe =
341        super::Request::parse_target(&request.method().to_string(), request.url(), Vec::new())
342            .with_headers(headers)
343            .with_remote(request.remote_addr().map(|a| a.ip()));
344    let accepted = match router.websocket(&probe) {
345        None => return Some(request),
346        Some(Ok(accepted)) => accepted,
347        Some(Err(response)) => {
348            watch.busy(request.remote_addr());
349            respond(request, response, false);
350            return None;
351        }
352    };
353    // a live channel is never closed by the read deadline: it waits by design
354    watch.live(request.remote_addr());
355    // and it ends when its client hangs up, not at its next write: an open channel keeps the
356    // server in use, so a closed one must stop counting within the idle timer's tick (I2131)
357    let hung_up = watch.hung_up(request.remote_addr());
358    let key = accepted.accept.clone();
359    let response = HttpResponse::empty(101).with_header(
360        Header::from_bytes("Sec-WebSocket-Accept", key.as_bytes())
361            .expect("the accept value is header-safe"),
362    );
363    let socket = request.upgrade("websocket", response);
364    let spawned = std::thread::Builder::new()
365        .name("events".into())
366        .spawn(move || {
367            OPEN_CHANNELS.fetch_add(1, Ordering::Relaxed);
368            accepted.serve_until(Box::new(socket), hung_up);
369            OPEN_CHANNELS.fetch_sub(1, Ordering::Relaxed);
370            note_request();
371        });
372    if let Err(e) = spawned {
373        tracing::warn!("a live channel could not be started: {e}");
374    }
375    None
376}
377
378fn answer_http(router: &Router, watch: &Watch, mut request: tiny_http::Request) {
379    let method = request.method().to_string();
380    let head = method == "HEAD";
381    let method = if head { "GET".to_string() } else { method };
382    let target = request.url().to_string();
383    let remote = request.remote_addr().map(|a| a.ip());
384    let headers: Vec<(String, String)> = request
385        .headers()
386        .iter()
387        .map(|h| (h.field.as_str().to_string(), h.value.as_str().to_string()))
388        .collect();
389    let mut body = Vec::new();
390    if let Err(e) = request
391        .as_reader()
392        .take(MAX_BODY_BYTES as u64 + 1)
393        .read_to_end(&mut body)
394    {
395        tracing::warn!("cannot read a request body: {e}");
396        return;
397    }
398    if !watch.busy(request.remote_addr()) {
399        // the deadline closed the connection under the body: what was read is short
400        return;
401    }
402    let response = if body.len() > MAX_BODY_BYTES {
403        // the rest of the body is never read, and dropping the request would read it
404        watch.stop_reading(request.remote_addr());
405        super::router::Response::error(
406            413,
407            "too_large",
408            &format!("the body is over {MAX_BODY_BYTES} bytes"),
409        )
410    } else {
411        router.handle(
412            &super::Request::parse_target(&method, &target, body)
413                .with_headers(headers)
414                .with_remote(remote),
415        )
416    };
417    // the target carries the query string, where a careless client puts a token
418    tracing::debug!(
419        method = %method,
420        target = %crate::redaction::redact_secrets(&target).text,
421        status = response.status,
422        "response"
423    );
424    respond(request, response, head);
425}
426
427/// Write one response and let the client go.
428fn respond(request: tiny_http::Request, response: super::router::Response, head: bool) {
429    // always Content-Length, never chunked: one less thing a small client must decode;
430    // a HEAD gets the GET's headers and no body
431    let (body, length) = wire_body(&response, head);
432    let mut out = HttpResponse::new(
433        response.status.into(),
434        Vec::new(),
435        std::io::Cursor::new(body),
436        Some(length),
437        None,
438    )
439    .with_chunked_threshold(usize::MAX)
440    .with_status_code(response.status)
441    .with_header(Header::from_bytes("Content-Type", response.content_type).expect("static header"));
442    // `no-store` is right for an answer derived from a repository that a person is editing;
443    // a response that named its own caching (a Cockpit asset whose URL carries its digest)
444    // keeps what it said
445    if !response
446        .headers
447        .iter()
448        .any(|(name, _)| name.eq_ignore_ascii_case("cache-control"))
449    {
450        out.add_header(Header::from_bytes("Cache-Control", "no-store").expect("static header"));
451    }
452    for (name, value) in &response.headers {
453        // the length is the server's to write, and `wire_body` has already read it
454        if name.eq_ignore_ascii_case("content-length") {
455            continue;
456        }
457        if let Ok(h) = Header::from_bytes(name.as_bytes(), value.as_bytes()) {
458            out.add_header(h);
459        }
460    }
461    if let Err(e) = request.respond(out) {
462        tracing::debug!("client went away before the response was written: {e}");
463    }
464}
465
466/// The bytes to write and the `Content-Length` to announce. A HEAD writes nothing. A
467/// `304 Not Modified` writes nothing either, and announces the length its producer names in
468/// a `Content-Length` header — the 200's, the only value RFC 9110 section 8.6 allows a 304
469/// to carry — or none of its own, an empty body's 0.
470fn wire_body(response: &super::router::Response, head: bool) -> (Vec<u8>, usize) {
471    if response.status == 304 {
472        let named = response
473            .headers
474            .iter()
475            .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
476            .and_then(|(_, value)| value.trim().parse().ok());
477        return (Vec::new(), named.unwrap_or(0));
478    }
479    let body: Vec<u8> = if head {
480        Vec::new()
481    } else {
482        response.body.as_bytes().to_vec()
483    };
484    let length = body.len();
485    (body, length)
486}
487
488/// Is stdin a pipe or a socket, as when a parent process spawned us and holds the other end?
489/// Asked of descriptor 0 itself (`fstat`), never of the path `/dev/stdin`: the path
490/// resolves through `/dev/fd`, which some harnesses (a coverage runner, a sandbox) do not
491/// expose, and a wrong "no" here sends `serve` into the run-forever branch while its
492/// parent waits for it to end.
493#[cfg(unix)]
494pub fn stdin_is_a_pipe() -> bool {
495    use std::os::fd::FromRawFd;
496    use std::os::unix::fs::FileTypeExt;
497    // SAFETY: descriptor 0 is the process's stdin for the process's lifetime; ManuallyDrop
498    // keeps this `File` from closing it.
499    let stdin = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(0) });
500    stdin
501        .metadata()
502        .map(|m| m.file_type().is_fifo() || m.file_type().is_socket())
503        .unwrap_or(false)
504}
505
506/// Is stdin a pipe or a socket? Not known on this platform.
507#[cfg(not(unix))]
508pub fn stdin_is_a_pipe() -> bool {
509    false
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn a_304_writes_no_body_and_announces_the_length_it_names() {
518        use super::super::router::Response;
519        let not_modified = Response::new(304, "text/css", "").with_header("Content-Length", "1234");
520        assert_eq!(wire_body(&not_modified, false), (Vec::new(), 1234));
521        let unnamed = Response::new(304, "text/css", "ignored");
522        assert_eq!(wire_body(&unnamed, false), (Vec::new(), 0));
523        let ok = Response::new(200, "text/css", "body{}");
524        assert_eq!(wire_body(&ok, false), (b"body{}".to_vec(), 6));
525        assert_eq!(
526            wire_body(&ok, true),
527            (Vec::new(), 0),
528            "a HEAD writes nothing"
529        );
530    }
531
532    #[test]
533    fn a_request_that_could_not_be_given_a_thread_gives_back_its_slot() {
534        let handlers = AtomicUsize::new(3);
535        released_unless_spawned(Err(std::io::Error::other("no threads left")), &handlers);
536        assert_eq!(handlers.load(Ordering::SeqCst), 2);
537        let spawned = std::thread::Builder::new().spawn(|| {});
538        released_unless_spawned(spawned, &handlers);
539        assert_eq!(
540            handlers.load(Ordering::SeqCst),
541            2,
542            "a request on its thread keeps its slot until it is answered"
543        );
544    }
545}