Skip to main content

hatchery_build/
host.rs

1//! The build host: admission, recipes, build, sign. The signing key is read once at start,
2//! kept in memory, never passed to a build, and never returned.
3
4use crate::http;
5use crate::wire::{hex, valid_name, valid_source_ref, BuildReceipt, BuildResponse, HostRequest};
6use async_trait::async_trait;
7use base64::engine::general_purpose::STANDARD as B64;
8use base64::Engine;
9use ed25519_dalek::SigningKey;
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12use std::collections::BTreeMap;
13use std::net::SocketAddr;
14use std::os::unix::fs::{MetadataExt, PermissionsExt};
15use std::path::{Path, PathBuf};
16use std::process::Stdio;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::net::TcpListener;
20use tokio::process::Command;
21use tokio::sync::{watch, Semaphore};
22
23#[derive(Debug, thiserror::Error)]
24pub enum BuildError {
25    #[error("node is not admitted for builds")]
26    Forbidden,
27    #[error("unknown recipe")]
28    UnknownRecipe,
29    #[error("bad request")]
30    BadRequest,
31    #[error("admission check unavailable: {0}")]
32    AdmissionUnavailable(String),
33    #[error("build failed: {0}")]
34    Failed(String),
35    #[error("build timed out")]
36    Timeout,
37    #[error("artifact missing or larger than the limit")]
38    Artifact,
39}
40
41/// Decides whether a node may build right now (node id + status + capability).
42#[async_trait]
43pub trait Admission: Send + Sync {
44    async fn admitted(&self, node_id: &str) -> Result<bool, String>;
45}
46
47/// Asks the C2 operator API: `POST /v1/enroll/nodes/<id>/allows {"capability":"build"}`.
48pub struct C2Admission {
49    pub api: SocketAddr,
50    pub token: String,
51}
52
53#[async_trait]
54impl Admission for C2Admission {
55    async fn admitted(&self, node_id: &str) -> Result<bool, String> {
56        if !valid_name(node_id, 64) {
57            return Ok(false);
58        }
59        let path = format!("/v1/enroll/nodes/{node_id}/allows");
60        let (status, body) = http::post(self.api, &path, Some(&self.token), br#"{"capability":"build"}"#, Duration::from_secs(5))
61            .await
62            .map_err(|e| e.to_string())?;
63        if status == 404 {
64            return Ok(false);
65        }
66        if status != 200 {
67            return Err(format!("C2 answered {status}"));
68        }
69        let value: serde_json::Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
70        Ok(value["allowed"].as_bool().unwrap_or(false))
71    }
72}
73
74pub struct Signer {
75    key: SigningKey,
76}
77
78impl Signer {
79    pub fn generate_to_file(path: &Path) -> Result<Self, String> {
80        let mut seed = [0_u8; 32];
81        getrandom::getrandom(&mut seed).map_err(|e| e.to_string())?;
82        hatchery_enroll::write_private(path, B64.encode(seed).as_bytes()).map_err(|e| e.to_string())?;
83        Ok(Self { key: SigningKey::from_bytes(&seed) })
84    }
85
86    /// The key file must be a regular file owned by the current user with mode 0600.
87    pub fn from_file(path: &Path) -> Result<Self, String> {
88        let meta = std::fs::symlink_metadata(path).map_err(|e| format!("signing key: {e}"))?;
89        // SAFETY: geteuid has no preconditions.
90        if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } || meta.permissions().mode() & 0o777 != 0o600 {
91            return Err("signing key file must be a regular file owned by this user with mode 0600".to_owned());
92        }
93        let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
94        let seed = hatchery_enroll::decode_key32(text.trim()).map_err(|e| e.to_string())?;
95        Ok(Self { key: SigningKey::from_bytes(&seed) })
96    }
97
98    pub fn from_seed(seed: [u8; 32]) -> Self {
99        Self { key: SigningKey::from_bytes(&seed) }
100    }
101
102    pub fn public_key(&self) -> String {
103        B64.encode(self.key.verifying_key().to_bytes())
104    }
105}
106
107#[derive(Clone, Debug, Deserialize)]
108pub struct Recipe {
109    pub argv: Vec<String>,
110    pub workdir: PathBuf,
111    /// Relative to `workdir`.
112    pub artifact: PathBuf,
113    #[serde(default = "default_timeout")]
114    pub timeout_secs: u64,
115    /// Run the build as this unprivileged user (needs root); the signing key's owner is
116    /// a different user, so the build cannot read the key file.
117    #[serde(default)]
118    pub run_as: Option<String>,
119}
120
121fn default_timeout() -> u64 {
122    600
123}
124
125#[derive(Clone, Debug, Default, Deserialize)]
126pub struct Recipes {
127    pub recipes: BTreeMap<String, Recipe>,
128}
129
130impl Recipes {
131    pub fn load(path: &Path) -> Result<Self, String> {
132        let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
133        let recipes: Self = serde_json::from_str(&text).map_err(|e| e.to_string())?;
134        for (name, r) in &recipes.recipes {
135            if !valid_name(name, 64) || r.argv.is_empty() || r.artifact.is_absolute() || r.artifact.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
136                return Err(format!("recipe '{name}' is invalid"));
137            }
138        }
139        Ok(recipes)
140    }
141}
142
143pub struct BuildHost {
144    recipes: Recipes,
145    admission: Arc<dyn Admission>,
146    signer: Signer,
147    one_at_a_time: Semaphore,
148    max_artifact: u64,
149}
150
151fn unix_ms() -> u64 {
152    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
153}
154
155impl BuildHost {
156    pub fn new(recipes: Recipes, admission: Arc<dyn Admission>, signer: Signer) -> Self {
157        Self { recipes, admission, signer, one_at_a_time: Semaphore::new(1), max_artifact: 16 * 1024 * 1024 }
158    }
159
160    pub fn signer_public_key(&self) -> String {
161        self.signer.public_key()
162    }
163
164    pub async fn build(&self, request: &HostRequest) -> Result<BuildResponse, BuildError> {
165        if !valid_name(&request.node_id, 64) || !valid_name(&request.recipe, 64) || request.source_ref.as_deref().is_some_and(|s| !valid_source_ref(s)) {
166            return Err(BuildError::BadRequest);
167        }
168        match self.admission.admitted(&request.node_id).await {
169            Ok(true) => {}
170            Ok(false) => return Err(BuildError::Forbidden),
171            Err(cause) => return Err(BuildError::AdmissionUnavailable(cause)),
172        }
173        let recipe = self.recipes.recipes.get(&request.recipe).ok_or(BuildError::UnknownRecipe)?;
174        let _turn = self.one_at_a_time.acquire().await.map_err(|_| BuildError::Failed("shutting down".into()))?;
175        // Admission may have changed while this request waited for its turn.
176        if !matches!(self.admission.admitted(&request.node_id).await, Ok(true)) {
177            return Err(BuildError::Forbidden);
178        }
179        let artifact_path = recipe.workdir.join(&recipe.artifact);
180        let _ = std::fs::remove_file(&artifact_path);
181        let mut command = Command::new(&recipe.argv[0]);
182        command
183            .args(&recipe.argv[1..])
184            .current_dir(&recipe.workdir)
185            .env_clear()
186            .env("PATH", std::env::var_os("PATH").unwrap_or_else(|| "/usr/local/bin:/usr/bin:/bin".into()))
187            .env("HOME", &recipe.workdir)
188            .env("HATCHERY_NODE_ID", &request.node_id)
189            .stdin(Stdio::null())
190            .stdout(Stdio::null())
191            .stderr(Stdio::piped())
192            .kill_on_drop(true);
193        if let Some(source_ref) = &request.source_ref {
194            command.env("HATCHERY_SOURCE_REF", source_ref);
195        }
196        if let Some(user) = &recipe.run_as {
197            let (uid, gid) = lookup_user(user).map_err(BuildError::Failed)?;
198            command.uid(uid).gid(gid);
199        }
200        let child = command.spawn().map_err(|e| BuildError::Failed(e.to_string()))?;
201        let output = tokio::time::timeout(Duration::from_secs(recipe.timeout_secs), child.wait_with_output())
202            .await
203            .map_err(|_| BuildError::Timeout)?
204            .map_err(|e| BuildError::Failed(e.to_string()))?;
205        if !output.status.success() {
206            let tail = String::from_utf8_lossy(&output.stderr);
207            let tail: String = tail.chars().rev().take(300).collect::<Vec<_>>().into_iter().rev().collect();
208            return Err(BuildError::Failed(format!("exit {:?}: {}", output.status.code(), tail.trim())));
209        }
210        let meta = std::fs::metadata(&artifact_path).map_err(|_| BuildError::Artifact)?;
211        if !meta.is_file() || meta.len() > self.max_artifact {
212            return Err(BuildError::Artifact);
213        }
214        let bytes = std::fs::read(&artifact_path).map_err(|_| BuildError::Artifact)?;
215        let receipt = BuildReceipt {
216            node_id: request.node_id.clone(),
217            recipe: request.recipe.clone(),
218            source_ref: request.source_ref.clone(),
219            artifact_sha256: hex(&Sha256::digest(&bytes)),
220            artifact_size: bytes.len() as u64,
221            built_at_ms: unix_ms(),
222            signer_public_key: String::new(),
223            signature: String::new(),
224        }
225        .sign(&self.signer.key);
226        Ok(BuildResponse { receipt, artifact_b64: B64.encode(&bytes) })
227    }
228}
229
230fn lookup_user(user: &str) -> Result<(u32, u32), String> {
231    let name = std::ffi::CString::new(user).map_err(|_| "bad user".to_owned())?;
232    // SAFETY: getpwnam returns static storage; fields are copied immediately.
233    let pw = unsafe { libc::getpwnam(name.as_ptr()) };
234    if pw.is_null() {
235        return Err(format!("user '{user}' does not exist"));
236    }
237    // SAFETY: non-null checked.
238    let (uid, gid) = unsafe { ((*pw).pw_uid, (*pw).pw_gid) };
239    if uid == 0 {
240        return Err("recipes must not run as root".to_owned());
241    }
242    Ok((uid, gid))
243}
244
245fn status_of(error: &BuildError) -> u16 {
246    match error {
247        BuildError::Forbidden => 403,
248        BuildError::UnknownRecipe => 404,
249        BuildError::BadRequest => 400,
250        BuildError::AdmissionUnavailable(_) => 502,
251        BuildError::Timeout => 504,
252        BuildError::Failed(_) | BuildError::Artifact => 422,
253    }
254}
255
256/// Serve `POST /v1/build` (bearer = the secret shared with C2 only; nodes never hold it).
257pub async fn serve(listener: TcpListener, host: Arc<BuildHost>, bearer: String, mut shutdown: watch::Receiver<bool>) -> std::io::Result<()> {
258    let bearer: Arc<str> = Arc::from(bearer);
259    loop {
260        let (mut stream, _) = tokio::select! {
261            accepted = listener.accept() => accepted?,
262            changed = shutdown.changed() => {
263                if changed.is_err() || *shutdown.borrow() { return Ok(()); }
264                continue;
265            }
266        };
267        let (host, bearer) = (Arc::clone(&host), Arc::clone(&bearer));
268        tokio::spawn(async move {
269            let Ok(Ok((method, path, token, body))) = tokio::time::timeout(Duration::from_secs(5), http::read_request(&mut stream, 4096)).await else {
270                let _ = http::respond(&mut stream, 400, br#"{"error":"bad request"}"#).await;
271                return;
272            };
273            if !token.as_deref().is_some_and(|t| http::secret_eq(t, &bearer)) {
274                let _ = http::respond(&mut stream, 401, br#"{"error":"unauthorized"}"#).await;
275                return;
276            }
277            if method != "POST" || path != "/v1/build" {
278                let _ = http::respond(&mut stream, 404, br#"{"error":"not found"}"#).await;
279                return;
280            }
281            let Ok(request) = serde_json::from_slice::<HostRequest>(&body) else {
282                let _ = http::respond(&mut stream, 400, br#"{"error":"malformed"}"#).await;
283                return;
284            };
285            match host.build(&request).await {
286                Ok(response) => {
287                    tracing::info!(node_id = %request.node_id, recipe = %request.recipe, sha256 = %response.receipt.artifact_sha256, "build signed");
288                    let _ = http::respond(&mut stream, 200, &serde_json::to_vec(&response).unwrap_or_default()).await;
289                }
290                Err(error) => {
291                    tracing::warn!(node_id = %request.node_id, recipe = %request.recipe, cause = %error, "build refused or failed");
292                    let body = serde_json::json!({ "error": error.to_string() });
293                    let _ = http::respond(&mut stream, status_of(&error), body.to_string().as_bytes()).await;
294                }
295            }
296        });
297    }
298}