1use 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#[async_trait]
43pub trait Admission: Send + Sync {
44 async fn admitted(&self, node_id: &str) -> Result<bool, String>;
45}
46
47pub 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 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 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 pub artifact: PathBuf,
113 #[serde(default = "default_timeout")]
114 pub timeout_secs: u64,
115 #[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 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 let pw = unsafe { libc::getpwnam(name.as_ptr()) };
234 if pw.is_null() {
235 return Err(format!("user '{user}' does not exist"));
236 }
237 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
256pub 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}