1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct EnrolledState {
34 pub node_id: String,
35 pub response: EnrollmentResponse,
36}
37
38pub 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#[derive(Clone, Debug)]
53pub enum EnrollTarget {
54 Plain(std::net::SocketAddr),
56 Https(String),
59}
60
61impl EnrollTarget {
62 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
84pub 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
95pub 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
120pub 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
129pub 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
139pub 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
188pub 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
216pub 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}