Skip to main content

fhd/
session.rs

1//! Daemon configuration, startup validation, and agent identity.
2
3use crate::active::ActiveBuildMap;
4use protocol::{write_json_frame, HelloAckPayload, MsgType};
5use std::path::PathBuf;
6use std::sync::Arc;
7use tokio::io::AsyncWrite;
8
9/// Best-effort identity of this agent, used for STATUS reporting and tags.
10///
11/// The environment wins over `gethostname(2)`: `HOSTNAME`/`COMPUTERNAME` is
12/// how operators relabel an agent in a pool (matching the `fhd` tag
13/// selection rules), and on most container/VM setups it is already set — so
14/// the FFI path is only reached on a bare-metal Unix host with no env var.
15#[allow(unsafe_code)] // FFI: gethostname(2) — SAFETY contract inside the body.
16pub fn get_hostname() -> String {
17    if let Ok(name) = std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")) {
18        if !name.is_empty() {
19            return name;
20        }
21    }
22
23    #[cfg(unix)]
24    {
25        // POSIX allows gethostname(2) to fill the buffer with no trailing NUL,
26        // hence the explicit length scan below.
27        let mut buf = [0u8; 256];
28        // SAFETY: `buf` is a live, writable array of 256 bytes; the cast to
29        // `*mut c_char` is only an aliasing view of the same bytes, and the
30        // length passed matches the array bound. gethostname never retains
31        // the pointer and writes at most `len` bytes.
32        let res = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
33        if res == 0 {
34            let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
35            if let Ok(s) = std::str::from_utf8(&buf[..len]) {
36                if !s.is_empty() {
37                    return s.to_string();
38                }
39            }
40        }
41    }
42
43    "fhd-agent".to_string()
44}
45
46#[derive(Clone)]
47pub struct ServerContext {
48    pub expected_token: Option<String>,
49    pub workdir_root: PathBuf,
50    pub custom_shell: Option<String>,
51    pub semaphore: Arc<tokio::sync::Semaphore>,
52    pub lock_manager: workspace::WorkspaceLockManager,
53    pub tags: Vec<String>,
54    pub queue_depth: Arc<std::sync::atomic::AtomicUsize>,
55    pub max_runs: usize,
56    pub min_disk_bytes: u64,
57    pub cas_store: Option<workspace::CasStore>,
58    pub start_time: std::time::Instant,
59    pub active_builds: ActiveBuildMap,
60    /// Caps concurrent client connections; excess sockets are closed on accept.
61    pub connection_limiter: Arc<tokio::sync::Semaphore>,
62    /// Caps how many runs may wait (project lock or concurrency semaphore)
63    /// before new runs are rejected with a queue-full error.
64    pub max_queued_runs: usize,
65}
66
67impl ServerContext {
68    /// Constant-time authorization check for a client-provided token.
69    ///
70    /// - No token configured (unauthenticated mode): every caller is authorized.
71    /// - Token configured: the provided token must match in constant time
72    ///   (see [`protocol::ct_eq_tokens`]). Empty configured tokens are treated
73    ///   as "no authentication" for compatibility with direct `run_server`
74    ///   callers; the CLI rejects them at startup.
75    pub fn authorize(&self, provided: Option<&str>) -> bool {
76        match self.expected_token.as_deref() {
77            None => true,
78            // Direct run_server callers only; the CLI rejects empty tokens.
79            Some("") => true,
80            Some(expected) => protocol::ct_eq_tokens(provided, Some(expected)),
81        }
82    }
83}
84
85/// Validate daemon startup authentication configuration.
86///
87/// The daemon refuses to start without a token unless unauthenticated mode is
88/// explicitly requested: an unauthenticated `fhd` is remote code execution by
89/// design. An empty token string is also rejected as a misconfiguration.
90pub fn validate_start_config(
91    token: Option<&str>,
92    allow_unauthenticated: bool,
93) -> Result<(), String> {
94    match token {
95        Some(t) if t.trim().is_empty() => Err(
96            "--token was set to an empty string. Set a real token via --token or the \
97             FARHAND_TOKEN environment variable, or pass --allow-unauthenticated to \
98             intentionally disable authentication."
99                .to_string(),
100        ),
101        Some(_) => Ok(()),
102        None if allow_unauthenticated => Ok(()),
103        None => Err(
104            "No authentication token configured. fhd executes commands sent by \
105             authenticated clients, so it refuses to start without a token.\n  \
106             Set one:    --token <secret>   (or FARHAND_TOKEN env var)\n  \
107             Dev only:   --allow-unauthenticated  (NEVER expose to untrusted networks)"
108                .to_string(),
109        ),
110    }
111}
112
113/// Default concurrent-connection cap when none is configured.
114pub(crate) const DEFAULT_MAX_CONNECTIONS: usize = 32;
115/// "Unlimited" sentinel for the connection limiter (tokio semaphores cap
116/// permits well below usize::MAX); a build agent will never approach this.
117pub(crate) const UNLIMITED_CONNECTIONS: usize = 1 << 20;
118
119/// Whether a listen address is exposed (unspecified address or any
120/// non-loopback IP). Unparseable addresses are treated conservatively as
121/// exposed.
122pub fn is_exposed_bind(listen: &str) -> bool {
123    match listen.parse::<std::net::SocketAddr>() {
124        Ok(addr) => !addr.ip().is_loopback(),
125        Err(_) => true,
126    }
127}
128
129/// Reply to an unauthorized pre-HELLO control request and return the error
130/// that closes the connection.
131///
132/// STATUS, HISTORY, and CLEAN all arrive before authentication and must fail
133/// in exactly the same shape, so the rejection lives in one place: a failure
134/// `HELLO_ACK` followed by an error that unwinds the connection. Sharing it
135/// keeps the three paths from drifting apart as they are edited.
136pub(crate) async fn deny_control_request<W: AsyncWrite + Unpin>(
137    stream: &mut W,
138    request: &str,
139) -> Result<(), Box<dyn std::error::Error>> {
140    let message = format!("Unauthorized {request} request");
141    let ack = HelloAckPayload {
142        ok: false,
143        error: Some(message.clone()),
144        compression: None,
145        remote_workdir: None,
146    };
147    write_json_frame(stream, MsgType::HelloAck, &ack).await?;
148    Err(message.into())
149}