1use hackamore_models::provision::{ProvisionAuth, ProvisionDoc, ProvisionMode, ProvisionService};
17use std::collections::BTreeSet;
18use std::path::{Path, PathBuf};
19
20const MANIFEST: &str = ".hackamore/manifest";
22const CA_BUNDLE: &str = ".hackamore/hackamore-ca.pem";
24
25pub async fn fetch_provision(proxy_url: &str, token: &str) -> Result<ProvisionDoc, String> {
30 let url = format!("{}/.hackamore/provision", proxy_url.trim_end_matches('/'));
31 let resp = reqwest::Client::new()
32 .get(&url)
33 .header("X-Hackamore-Token", token)
34 .send()
35 .await
36 .map_err(|e| format!("provision request failed: {e}"))?;
37 if !resp.status().is_success() {
38 return Err(format!("provision failed: HTTP {}", resp.status()));
39 }
40 resp.json()
41 .await
42 .map_err(|e| format!("provision decode failed: {e}"))
43}
44
45pub fn render_env(doc: &ProvisionDoc) -> String {
47 let mut out = format!(
48 "# hackamore-agent env (token expires at {} ms)\nexport HACKAMORE_TOKEN='{}'\n\
49 export HACKAMORE_TOKEN_HEADER='X-Hackamore-Token'\n",
50 doc.expires_at_ms, doc.hackamore_token
51 );
52 if !doc.hackamore_ca.is_empty() {
53 out.push_str(&format!(
55 "export HACKAMORE_CA_BUNDLE=\"$HOME/{CA_BUNDLE}\"\n\
56 export AWS_CA_BUNDLE=\"$HOME/{CA_BUNDLE}\"\n\
57 export GIT_SSL_CAINFO=\"$HOME/{CA_BUNDLE}\"\n"
58 ));
59 }
60 for s in &doc.services {
61 out.push_str(&format!(
62 "# service '{}' [{}] {}\n",
63 s.target,
64 s.flavor,
65 mode_hint(&s.mode)
66 ));
67 if !s.address.is_empty() {
68 out.push_str(&format!("# point your tool at: {}\n", s.address));
69 }
70 }
71 out
72}
73
74pub fn render_status(doc: &ProvisionDoc) -> String {
76 let mut out = format!(
77 "hackamore token valid until {} ms; {} service(s) reachable:\n",
78 doc.expires_at_ms,
79 doc.services.len()
80 );
81 for s in &doc.services {
82 let addr = if s.address.is_empty() {
83 "(via hackamore proxy)".to_string()
84 } else {
85 s.address.clone()
86 };
87 out.push_str(&format!(
88 " - {} [{}] {} → {}\n",
89 s.target,
90 s.flavor,
91 mode_hint(&s.mode),
92 addr
93 ));
94 }
95 out
96}
97
98fn mode_hint(mode: &ProvisionMode) -> &'static str {
99 match mode {
100 ProvisionMode::Inject => "inject (hackamore supplies the credential)",
101 ProvisionMode::Passthrough => "passthrough (bring your own credential)",
102 }
103}
104
105pub fn write_configs(home: &Path, doc: &ProvisionDoc) -> std::io::Result<Vec<PathBuf>> {
110 let mut written: Vec<PathBuf> = Vec::new();
111 written.push(write(&home.join("hackamore.env"), &render_env(doc))?);
112
113 let ca_path = if doc.hackamore_ca.is_empty() {
115 None
116 } else {
117 let p = home.join(CA_BUNDLE);
118 written.push(write(&p, &doc.hackamore_ca)?);
119 Some(p)
120 };
121
122 for s in &doc.services {
123 match s.flavor.as_str() {
124 "github" => written.extend(write_github(home, s, ca_path.as_deref())?),
125 "k8s" => written.push(write_kubeconfig(home, s, ca_path.as_deref())?),
126 _ => {}
127 }
128 if let ProvisionAuth::SigV4(a) = &s.auth {
129 written.extend(write_aws(home, s, a, ca_path.as_deref())?);
130 }
131 }
132
133 write_manifest(home, &written)?;
134 Ok(written)
135}
136
137pub fn teardown(home: &Path) -> std::io::Result<Vec<PathBuf>> {
141 let manifest = home.join(MANIFEST);
142 let listing = match std::fs::read_to_string(&manifest) {
143 Ok(text) => text,
144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
145 Err(e) => return Err(e),
146 };
147 let mut removed = Vec::new();
148 for line in listing.lines().filter(|l| !l.trim().is_empty()) {
149 let path = PathBuf::from(line);
150 match std::fs::remove_file(&path) {
151 Ok(()) => removed.push(path),
152 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
153 Err(e) => return Err(e),
154 }
155 }
156 let _ = std::fs::remove_file(&manifest);
157 Ok(removed)
158}
159
160fn bearer_token(s: &ProvisionService) -> Option<&str> {
162 match &s.auth {
163 ProvisionAuth::Bearer(b) => Some(&b.token),
164 ProvisionAuth::SigV4(_) => None,
165 }
166}
167
168fn endpoint(s: &ProvisionService) -> &str {
169 if s.address.is_empty() {
170 "https://hackamore.local"
171 } else {
172 &s.address
173 }
174}
175
176fn endpoint_host(s: &ProvisionService) -> &str {
178 endpoint(s)
179 .trim_start_matches("https://")
180 .trim_start_matches("http://")
181 .trim_end_matches('/')
182}
183
184fn write_kubeconfig(
188 home: &Path,
189 s: &ProvisionService,
190 ca: Option<&Path>,
191) -> std::io::Result<PathBuf> {
192 let token = bearer_token(s).unwrap_or_default();
193 let name = &s.target;
194 let cluster_tls = match ca {
195 Some(p) => format!(" certificate-authority: {}\n", p.display()),
196 None => String::new(),
197 };
198 let body = format!(
199 "apiVersion: v1\nkind: Config\ncurrent-context: {name}\n\
200 clusters:\n- name: {name}\n cluster:\n server: {server}\n{cluster_tls}\
201 contexts:\n- name: {name}\n context:\n cluster: {name}\n user: {name}\n\
202 users:\n- name: {name}\n user:\n token: {token}\n",
203 server = endpoint(s),
204 );
205 write(&home.join(".kube").join("config"), &body)
206}
207
208fn write_github(
212 home: &Path,
213 s: &ProvisionService,
214 ca: Option<&Path>,
215) -> std::io::Result<Vec<PathBuf>> {
216 let token = bearer_token(s).unwrap_or_default();
217 let host = endpoint_host(s);
218
219 let cred_line = format!("https://x-access-token:{token}@{host}");
222 let creds = home.join(".git-credentials");
223 let merged = merge_lines(&creds, &cred_line)?;
224 let creds = write(&creds, &merged)?;
225
226 let mut gitconfig = String::from("[credential]\n\thelper = store\n");
228 if let Some(p) = ca {
229 gitconfig.push_str(&format!("[http]\n\tsslCAInfo = {}\n", p.display()));
230 }
231 let gitconfig = write(&home.join(".gitconfig"), &gitconfig)?;
232
233 let hosts = format!(
235 "{host}:\n oauth_token: {token}\n git_protocol: https\n user: x-access-token\n"
236 );
237 let gh = write(&home.join(".config").join("gh").join("hosts.yml"), &hosts)?;
238
239 Ok(vec![creds, gitconfig, gh])
240}
241
242fn write_aws(
246 home: &Path,
247 s: &ProvisionService,
248 a: &hackamore_models::provision::SigV4Auth,
249 ca: Option<&Path>,
250) -> std::io::Result<Vec<PathBuf>> {
251 let creds = format!(
252 "[default]\naws_access_key_id = {}\naws_secret_access_key = {}\n",
253 a.access_key_id, a.secret_access_key
254 );
255 let mut config = format!(
256 "[default]\nregion = {}\nendpoint_url = {}\n",
257 a.region,
258 endpoint(s)
259 );
260 if let Some(p) = ca {
261 config.push_str(&format!("ca_bundle = {}\n", p.display()));
262 }
263 Ok(vec![
264 write(&home.join(".aws").join("credentials"), &creds)?,
265 write(&home.join(".aws").join("config"), &config)?,
266 ])
267}
268
269fn merge_lines(path: &Path, line: &str) -> std::io::Result<String> {
273 let mut seen: BTreeSet<String> = BTreeSet::new();
274 let mut ordered: Vec<String> = Vec::new();
275 let existing = match std::fs::read_to_string(path) {
276 Ok(text) => text,
277 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
278 Err(e) => return Err(e),
279 };
280 for l in existing.lines().chain(std::iter::once(line)) {
281 let l = l.trim();
282 if !l.is_empty() && seen.insert(l.to_string()) {
283 ordered.push(l.to_string());
284 }
285 }
286 let mut out = ordered.join("\n");
287 out.push('\n');
288 Ok(out)
289}
290
291fn write_manifest(home: &Path, written: &[PathBuf]) -> std::io::Result<()> {
294 let body = written
295 .iter()
296 .map(|p| p.display().to_string())
297 .collect::<Vec<_>>()
298 .join("\n");
299 write(&home.join(MANIFEST), &format!("{body}\n"))?;
300 Ok(())
301}
302
303fn write(path: &Path, contents: &str) -> std::io::Result<PathBuf> {
305 if let Some(parent) = path.parent() {
306 std::fs::create_dir_all(parent)?;
307 }
308 std::fs::write(path, contents)?;
309 Ok(path.to_path_buf())
310}
311
312#[cfg(test)]
313#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
314mod tests {
315 use super::*;
316 use hackamore_models::provision::{BearerAuth, SigV4Auth};
317
318 fn svc(
319 target: &str,
320 flavor: &str,
321 auth: ProvisionAuth,
322 mode: ProvisionMode,
323 ) -> ProvisionService {
324 ProvisionService {
325 target: target.into(),
326 flavor: flavor.into(),
327 address: String::new(),
328 mode,
329 auth,
330 }
331 }
332
333 fn doc_with_ca(ca: &str) -> ProvisionDoc {
334 ProvisionDoc {
335 hackamore_token: "tok-abc".into(),
336 hackamore_ca: ca.into(),
337 expires_at_ms: 12345,
338 services: vec![
339 svc(
340 "github",
341 "github",
342 ProvisionAuth::Bearer(BearerAuth {
343 token: "tok-abc".into(),
344 }),
345 ProvisionMode::Inject,
346 ),
347 svc(
348 "eks-prod",
349 "k8s",
350 ProvisionAuth::Bearer(BearerAuth {
351 token: "tok-abc".into(),
352 }),
353 ProvisionMode::Inject,
354 ),
355 svc(
356 "aws-acct-a",
357 "generic",
358 ProvisionAuth::SigV4(SigV4Auth {
359 access_key_id: "AKIADUMMY".into(),
360 secret_access_key: "dummy-secret".into(),
361 region: "us-east-1".into(),
362 }),
363 ProvisionMode::Inject,
364 ),
365 ],
366 }
367 }
368
369 fn doc() -> ProvisionDoc {
370 doc_with_ca("")
371 }
372
373 fn temp_home(tag: &str) -> PathBuf {
374 let dir =
375 std::env::temp_dir().join(format!("hackamore-agent-test-{tag}-{}", std::process::id()));
376 let _ = std::fs::remove_dir_all(&dir);
377 dir
378 }
379
380 #[test]
381 fn env_exports_token_and_lists_services() {
382 let env = render_env(&doc());
383 assert!(env.contains("export HACKAMORE_TOKEN='tok-abc'"));
384 assert!(env.contains("service 'github'"));
385 assert!(env.contains("service 'aws-acct-a'"));
386 assert!(!env.contains("CA_BUNDLE"));
388 }
389
390 #[test]
391 fn write_configs_writes_native_files_into_home() {
392 let dir = temp_home("native");
393 let written = write_configs(&dir, &doc()).unwrap();
394 assert!(written.iter().any(|p| p.ends_with("hackamore.env")));
395
396 let kube = std::fs::read_to_string(dir.join(".kube").join("config")).unwrap();
397 assert!(kube.contains("token: tok-abc"));
398 assert!(kube.contains("kind: Config"));
399 assert!(!kube.contains("certificate-authority"));
401
402 let creds = std::fs::read_to_string(dir.join(".aws").join("credentials")).unwrap();
403 assert!(creds.contains("aws_access_key_id = AKIADUMMY"));
404 assert!(creds.contains("aws_secret_access_key = dummy-secret"));
405
406 let git = std::fs::read_to_string(dir.join(".git-credentials")).unwrap();
407 assert!(git.contains("x-access-token:tok-abc@"));
408
409 let gitconfig = std::fs::read_to_string(dir.join(".gitconfig")).unwrap();
411 assert!(gitconfig.contains("helper = store"));
412
413 let gh = std::fs::read_to_string(dir.join(".config").join("gh").join("hosts.yml")).unwrap();
415 assert!(gh.contains("oauth_token: tok-abc"));
416 assert!(gh.contains("git_protocol: https"));
417
418 assert!(written.iter().all(|p| p.starts_with(&dir)));
420 let _ = std::fs::remove_dir_all(&dir);
421 }
422
423 #[test]
424 fn tls_ca_is_written_and_referenced_by_every_tool() {
425 let dir = temp_home("tls");
426 let written = write_configs(
427 &dir,
428 &doc_with_ca("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----"),
429 )
430 .unwrap();
431 let ca_path = dir.join(CA_BUNDLE);
432 assert!(written.contains(&ca_path));
433 let ca = std::fs::read_to_string(&ca_path).unwrap();
434 assert!(ca.contains("BEGIN CERTIFICATE"));
435
436 let kube = std::fs::read_to_string(dir.join(".kube").join("config")).unwrap();
437 assert!(kube.contains(&format!("certificate-authority: {}", ca_path.display())));
438
439 let aws = std::fs::read_to_string(dir.join(".aws").join("config")).unwrap();
440 assert!(aws.contains(&format!("ca_bundle = {}", ca_path.display())));
441
442 let gitconfig = std::fs::read_to_string(dir.join(".gitconfig")).unwrap();
443 assert!(gitconfig.contains(&format!("sslCAInfo = {}", ca_path.display())));
444
445 let env = render_env(&doc_with_ca("x"));
446 assert!(env.contains("AWS_CA_BUNDLE"));
447 let _ = std::fs::remove_dir_all(&dir);
448 }
449
450 #[test]
451 fn git_credentials_merge_idempotently() {
452 let dir = temp_home("merge");
453 std::fs::create_dir_all(&dir).unwrap();
454 let creds = dir.join(".git-credentials");
455 std::fs::write(&creds, "https://x-access-token:other@github.example\n").unwrap();
457 write_configs(&dir, &doc()).unwrap();
458 let body = std::fs::read_to_string(&creds).unwrap();
459 assert!(
460 body.contains("other@github.example"),
461 "pre-existing line preserved"
462 );
463 assert!(body.contains("tok-abc@"), "hackamore line added");
464 write_configs(&dir, &doc()).unwrap();
466 let body2 = std::fs::read_to_string(&creds).unwrap();
467 assert_eq!(body2.matches("tok-abc@").count(), 1);
468 let _ = std::fs::remove_dir_all(&dir);
469 }
470
471 #[test]
472 fn teardown_removes_exactly_what_was_written() {
473 let dir = temp_home("teardown");
474 let written = write_configs(&dir, &doc()).unwrap();
475 for p in &written {
476 assert!(p.exists());
477 }
478 let removed = teardown(&dir).unwrap();
479 for p in &written {
481 assert!(!p.exists(), "{} should be removed", p.display());
482 }
483 assert_eq!(removed.len(), written.len());
484 assert!(!dir.join(MANIFEST).exists());
486 assert_eq!(teardown(&dir).unwrap().len(), 0);
487 let _ = std::fs::remove_dir_all(&dir);
488 }
489}