Skip to main content

hatchery_node/
enroll_client.rs

1//! Node side of enrollment: key generation in the node's own state directory, the signed
2//! request with the operator's one-time code, and the stored public answer.
3//!
4//! Keys live in `<state dir>/identity.key` and `<state dir>/wg.key` (directory 0700, files
5//! 0600) and never leave the host; only public keys are sent. Run provider agents as another
6//! user (or inside a guest) so they cannot read this directory.
7
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use hatchery_enroll::{EnrollmentResponse, NodeIdentity, NodeKind};
12use serde::{Deserialize, Serialize};
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tokio::net::TcpStream;
15use tokio::time::timeout;
16
17const ENROLLMENT_FILE: &str = "enrollment.json";
18
19#[derive(Debug, thiserror::Error)]
20pub enum EnrollClientError {
21    #[error("enrollment request failed: {0}")]
22    Transport(String),
23    #[error("C2 refused the request (for enrollment: the one-time code is single use and expires; for a build: the node status must hold the build capability)")]
24    Refused,
25    #[error("C2 answered with an unexpected reply: {0}")]
26    Reply(String),
27    #[error("key or state error: {0}")]
28    State(String),
29}
30
31/// Public result of a successful enrollment, stored next to the keys.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct EnrolledState {
34    pub node_id: String,
35    pub response: EnrollmentResponse,
36}
37
38/// Default state directory: `$XDG_STATE_HOME/gate4agent-node/enroll` or `$HOME/.local/state/...`.
39pub fn default_enroll_dir() -> Option<PathBuf> {
40    if let Some(base) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
41        return Some(PathBuf::from(base).join("gate4agent-node").join("enroll"));
42    }
43    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
44    Some(PathBuf::from(home).join(".local").join("state").join("gate4agent-node").join("enroll"))
45}
46
47fn unix_ms() -> u64 {
48    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
49}
50
51/// Where to send the enrollment request.
52#[derive(Clone, Debug)]
53pub enum EnrollTarget {
54    /// Plain TCP (loopback rigs, old C2s). The one-time code crosses the network in the clear.
55    Plain(std::net::SocketAddr),
56    /// `https://host[:port]`: the C2's TLS front (certificate verified against the WebPKI roots,
57    /// host name checked, no redirects). The code is only ever sent encrypted.
58    Https(String),
59}
60
61impl EnrollTarget {
62    /// `https://...` or `HOST:PORT`. Plain `http://` is refused: it adds nothing over HOST:PORT
63    /// and looks safer than it is.
64    pub fn parse(value: &str) -> Result<Self, String> {
65        if let Some(rest) = value.strip_prefix("https://") {
66            let host = rest.trim_end_matches('/');
67            if host.is_empty() || host.contains('/') || host.contains('@') {
68                return Err("https target must be https://HOST[:PORT]".into());
69            }
70            return Ok(Self::Https(format!("https://{host}")));
71        }
72        if value.starts_with("http://") {
73            return Err("use https://HOST or HOST:PORT, not http://".into());
74        }
75        {
76            use std::net::ToSocketAddrs;
77            let mut addrs: Vec<_> = value.to_socket_addrs().map_err(|e| format!("{value}: {e}"))?.collect();
78            addrs.sort_by_key(|a| !a.is_ipv4());
79            addrs.into_iter().next().map(Self::Plain).ok_or_else(|| format!("{value} resolves to no address"))
80        }
81    }
82}
83
84/// Generate (or load) the keys, send the signed request, store the public answer.
85pub async fn enroll(
86    enroll_addr: std::net::SocketAddr,
87    node_id: &str,
88    kind: NodeKind,
89    code: Option<&str>,
90    state_dir: &Path,
91) -> Result<EnrolledState, EnrollClientError> {
92    enroll_to(&EnrollTarget::Plain(enroll_addr), node_id, kind, code, state_dir).await
93}
94
95/// [`enroll`] to any [`EnrollTarget`].
96pub async fn enroll_to(
97    target: &EnrollTarget,
98    node_id: &str,
99    kind: NodeKind,
100    code: Option<&str>,
101    state_dir: &Path,
102) -> Result<EnrolledState, EnrollClientError> {
103    let identity = NodeIdentity::load_or_create(state_dir).map_err(|e| EnrollClientError::State(e.to_string()))?;
104    let request = identity.sign_request(node_id, kind, code, unix_ms()).map_err(|e| EnrollClientError::State(e.to_string()))?;
105    let body = serde_json::to_vec(&request).map_err(|e| EnrollClientError::State(e.to_string()))?;
106    let reply = match target {
107        EnrollTarget::Plain(addr) => post(*addr, "/v1/enroll", &body).await?,
108        EnrollTarget::Https(base) => post_https(base, "/v1/enroll", body).await?,
109    };
110    if reply.0 != 200 {
111        return Err(EnrollClientError::Refused);
112    }
113    let response: EnrollmentResponse = serde_json::from_slice(&reply.1).map_err(|e| EnrollClientError::Reply(e.to_string()))?;
114    let state = EnrolledState { node_id: node_id.to_owned(), response };
115    let json = serde_json::to_vec_pretty(&state).map_err(|e| EnrollClientError::State(e.to_string()))?;
116    hatchery_enroll::write_private(&state_dir.join(ENROLLMENT_FILE), &json).map_err(|e| EnrollClientError::State(e.to_string()))?;
117    Ok(state)
118}
119
120/// Load the identity and the stored public answer of an enrolled node.
121pub fn load_enrolled(state_dir: &Path) -> Result<(NodeIdentity, EnrolledState), EnrollClientError> {
122    let identity = NodeIdentity::load_or_create(state_dir).map_err(|e| EnrollClientError::State(e.to_string()))?;
123    let bytes = std::fs::read(state_dir.join(ENROLLMENT_FILE))
124        .map_err(|_| EnrollClientError::State("not enrolled yet: run `enroll` first".to_owned()))?;
125    let state = serde_json::from_slice(&bytes).map_err(|e| EnrollClientError::State(e.to_string()))?;
126    Ok((identity, state))
127}
128
129/// Load the identity for signing only (no WireGuard key is read) and the enrolled state.
130/// For a node whose tunnel is brought up by a privileged helper.
131pub fn load_enrolled_signing_only(state_dir: &Path) -> Result<(NodeIdentity, EnrolledState), EnrollClientError> {
132    let identity = NodeIdentity::load_signing_only(state_dir).map_err(|e| EnrollClientError::State(e.to_string()))?;
133    let bytes = std::fs::read(state_dir.join(ENROLLMENT_FILE))
134        .map_err(|_| EnrollClientError::State("not enrolled yet: run `enroll` first".to_owned()))?;
135    let state = serde_json::from_slice(&bytes).map_err(|e| EnrollClientError::State(e.to_string()))?;
136    Ok((identity, state))
137}
138
139/// Ask C2 to build and sign a configured recipe on the build host. The node proves who it is
140/// with its identity key; it holds no build-host credential and never sees a signing key.
141/// The receipt is checked against `trusted_signer` (the operator-provided public key of the
142/// build host) and the artifact against the signed hash before it is returned.
143pub async fn request_build(
144    c2_enroll: std::net::SocketAddr,
145    identity: &NodeIdentity,
146    node_id: &str,
147    recipe: &str,
148    source_ref: Option<&str>,
149    trusted_signer: &str,
150) -> Result<(hatchery_build::BuildReceipt, Vec<u8>), EnrollClientError> {
151    use base64::Engine;
152    use hatchery_build::wire::SignedBuildRequest;
153    let ts_ms = unix_ms();
154    let mut raw = [0_u8; 12];
155    std::fs::File::open("/dev/urandom")
156        .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut raw))
157        .map_err(|e| EnrollClientError::State(e.to_string()))?;
158    let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
159    let message = SignedBuildRequest::signing_bytes(node_id, recipe, source_ref, ts_ms, &nonce);
160    let request = SignedBuildRequest {
161        node_id: node_id.to_owned(),
162        recipe: recipe.to_owned(),
163        source_ref: source_ref.map(str::to_owned),
164        ts_ms,
165        nonce,
166        signature: identity.sign_bytes(&message),
167    };
168    let body = serde_json::to_vec(&request).map_err(|e| EnrollClientError::State(e.to_string()))?;
169    let (status, reply) = hatchery_build::http::post(c2_enroll, "/v1/build", None, &body, Duration::from_secs(25 * 60))
170        .await
171        .map_err(|e| EnrollClientError::Transport(e.to_string()))?;
172    match status {
173        200 => {}
174        403 => return Err(EnrollClientError::Refused),
175        other => return Err(EnrollClientError::Reply(format!("{other}: {}", String::from_utf8_lossy(&reply).chars().take(200).collect::<String>()))),
176    }
177    let response: hatchery_build::BuildResponse = serde_json::from_slice(&reply).map_err(|e| EnrollClientError::Reply(e.to_string()))?;
178    let artifact = base64::engine::general_purpose::STANDARD
179        .decode(&response.artifact_b64)
180        .map_err(|e| EnrollClientError::Reply(e.to_string()))?;
181    response
182        .receipt
183        .verify(trusted_signer, Some(&artifact))
184        .map_err(|e| EnrollClientError::Reply(format!("receipt rejected: {e}")))?;
185    Ok((response.receipt, artifact))
186}
187
188/// Make a signed mail/task call through C2 (`op` like `mail.send`, `task.list`). The node proves
189/// who it is with its identity key; no server credential is involved. Returns the reply JSON.
190pub async fn node_call(
191    c2_enroll: std::net::SocketAddr,
192    identity: &NodeIdentity,
193    node_id: &str,
194    op: &str,
195    payload: &serde_json::Value,
196) -> Result<serde_json::Value, EnrollClientError> {
197    use hatchery_enroll::call::SignedCall;
198    let ts_ms = unix_ms();
199    let mut raw = [0_u8; 12];
200    getrandom::fill(&mut raw).map_err(|e| EnrollClientError::State(e.to_string()))?;
201    let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
202    let payload_text = payload.to_string();
203    let message = SignedCall::signing_bytes(node_id, op, &payload_text, ts_ms, &nonce);
204    let call = SignedCall { node_id: node_id.to_owned(), op: op.to_owned(), payload: payload_text, ts_ms, nonce, signature: identity.sign_bytes(&message) };
205    let body = serde_json::to_vec(&call).map_err(|e| EnrollClientError::State(e.to_string()))?;
206    let (status, reply) = hatchery_build::http::post(c2_enroll, "/v1/node-call", None, &body, Duration::from_secs(45))
207        .await
208        .map_err(|e| EnrollClientError::Transport(e.to_string()))?;
209    match status {
210        200 => serde_json::from_slice(&reply).map_err(|e| EnrollClientError::Reply(e.to_string())),
211        403 => Err(EnrollClientError::Refused),
212        other => Err(EnrollClientError::Reply(format!("{other}: {}", String::from_utf8_lossy(&reply).chars().take(200).collect::<String>()))),
213    }
214}
215
216/// Environment the kernel WireGuard bring-up reads, from the enrolled state. Only variables
217/// that are unset are filled. Returns the call-home address when the answer carries one.
218pub fn wireguard_env_from_state(state_dir: &Path, state: &EnrolledState, interface: &str) -> Vec<(String, String)> {
219    let r = &state.response;
220    let mut out = Vec::new();
221    let mut push = |k: &str, v: Option<String>| {
222        if let Some(v) = v {
223            out.push((k.to_owned(), v));
224        }
225    };
226    push("GATE4AGENT_WG_INTERFACE", Some(interface.to_owned()));
227    push("GATE4AGENT_WG_PRIVATE_KEY", Some(NodeIdentity::wg_path(state_dir).to_string_lossy().into_owned()));
228    push("GATE4AGENT_WG_ADDRESS", r.tunnel_ip.clone());
229    push("GATE4AGENT_WG_PEER_ADDRESS", r.c2_tunnel_ip.clone());
230    push("GATE4AGENT_WG_PEER_KEY", r.c2_wg_public_key.clone());
231    push("GATE4AGENT_WG_PEER_ENDPOINT", r.c2_wg_endpoint.clone());
232    out
233}
234
235async fn post_https(base: &str, path: &str, body: Vec<u8>) -> Result<(u16, Vec<u8>), EnrollClientError> {
236    let client = reqwest::Client::builder()
237        .use_rustls_tls()
238        .https_only(true)
239        .redirect(reqwest::redirect::Policy::none())
240        .timeout(Duration::from_secs(15))
241        .build()
242        .map_err(|e| EnrollClientError::Transport(e.to_string()))?;
243    let response = client
244        .post(format!("{base}{path}"))
245        .header("content-type", "application/json")
246        .body(body)
247        .send()
248        .await
249        .map_err(|e| EnrollClientError::Transport(format!("https: {e}")))?;
250    let status = response.status().as_u16();
251    let bytes = response.bytes().await.map_err(|e| EnrollClientError::Transport(e.to_string()))?;
252    Ok((status, bytes.to_vec()))
253}
254
255async fn post(addr: std::net::SocketAddr, path: &str, body: &[u8]) -> Result<(u16, Vec<u8>), EnrollClientError> {
256    let io = async {
257        let mut stream = TcpStream::connect(addr).await?;
258        let head = format!(
259            "POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
260            body.len()
261        );
262        stream.write_all(head.as_bytes()).await?;
263        stream.write_all(body).await?;
264        let mut raw = Vec::new();
265        stream.read_to_end(&mut raw).await?;
266        Ok::<_, std::io::Error>(raw)
267    };
268    let raw = timeout(Duration::from_secs(15), io)
269        .await
270        .map_err(|_| EnrollClientError::Transport("timed out".to_owned()))?
271        .map_err(|e| EnrollClientError::Transport(e.to_string()))?;
272    let split = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| EnrollClientError::Reply("no header end".to_owned()))?;
273    let head = String::from_utf8_lossy(&raw[..split]);
274    let status = head
275        .split_whitespace()
276        .nth(1)
277        .and_then(|s| s.parse().ok())
278        .ok_or_else(|| EnrollClientError::Reply("bad status line".to_owned()))?;
279    Ok((status, raw[split + 4..].to_vec()))
280}
281
282#[cfg(test)]
283mod target_tests {
284    use super::EnrollTarget;
285
286    #[test]
287    fn https_and_plain_targets_parse_and_http_is_refused() {
288        assert!(matches!(EnrollTarget::parse("https://enroll.node.example"), Ok(EnrollTarget::Https(u)) if u == "https://enroll.node.example"));
289        assert!(matches!(EnrollTarget::parse("https://enroll.example:8443/"), Ok(EnrollTarget::Https(u)) if u == "https://enroll.example:8443"));
290        assert!(matches!(EnrollTarget::parse("127.0.0.1:18421"), Ok(EnrollTarget::Plain(_))));
291        assert!(EnrollTarget::parse("http://enroll.example").is_err());
292        assert!(EnrollTarget::parse("https://user@host").is_err());
293        assert!(EnrollTarget::parse("https://host/path").is_err());
294    }
295}