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}