Skip to main content

hackamore_agent/
lib.rs

1//! Consumer-side provisioning: fetch the [`ProvisionDoc`] from the reserved
2//! `/.hackamore/provision` path on hackamore's proxy listener — the only address a sandboxed
3//! consumer can reach — and render it into native tool config. [`write_configs`]
4//! writes everything **under a
5//! caller-supplied home directory** — nothing outside it is touched, so a sandbox (or a
6//! test) can configure stock tools without polluting the host's real `~/.kube`, `~/.aws`,
7//! or git config.
8//!
9//! Every write is recorded in a manifest (`<home>/.hackamore/manifest`) so [`teardown`] can
10//! remove exactly what hackamore wrote and nothing else. Line-oriented files (git
11//! credentials) are merged idempotently rather than clobbered, so re-provisioning a second
12//! service doesn't drop the first. When hackamore terminates TLS, the doc carries a CA bundle
13//! ([`ProvisionDoc::hackamore_ca`]); it is written once and referenced by path from every
14//! tool's config (kubeconfig, `~/.aws/config`, `.gitconfig`).
15
16use hackamore_models::provision::{ProvisionAuth, ProvisionDoc, ProvisionMode, ProvisionService};
17use std::collections::BTreeSet;
18use std::path::{Path, PathBuf};
19
20/// Relative path (under the home) of the manifest listing every file hackamore wrote.
21const MANIFEST: &str = ".hackamore/manifest";
22/// Relative path (under the home) of the CA bundle, when hackamore terminates TLS.
23const CA_BUNDLE: &str = ".hackamore/hackamore-ca.pem";
24
25/// Fetch the provision doc from the reserved `/.hackamore/provision` path on the proxy
26/// listener at `proxy_url`, presenting the token via `X-Hackamore-Token`. The proxy
27/// listener is the only address a sandboxed consumer can reach; the admin listener
28/// (which also serves the unauthenticated `/mint`) stays operator-only.
29pub 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
45/// Render shell `export` lines from a provision doc.
46pub 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        // Point TLS-aware tools that read env (curl, some SDKs) at the bundle.
54        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
74/// Render a human-readable summary.
75pub 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
105/// Write native tool config for every service into `home` (an isolated directory). Returns
106/// the files written and records them in the manifest. Always writes `hackamore.env` and (when
107/// hackamore terminates TLS) the CA bundle; per service it writes git config (github), a
108/// kubeconfig (k8s), and/or an AWS profile (SigV4).
109pub 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    // The CA bundle is written once and referenced by path from each tool's config.
114    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
137/// Remove every file hackamore previously wrote under `home`, per its manifest, then the
138/// manifest itself. Returns the files removed. Idempotent: a missing manifest or
139/// already-removed file is not an error. Nothing outside the manifest is touched.
140pub 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
160/// The bearer (hackamore) token a service presents, if its auth is bearer.
161fn 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
176/// The bare host[:port] of a service's consumer-facing endpoint.
177fn endpoint_host(s: &ProvisionService) -> &str {
178    endpoint(s)
179        .trim_start_matches("https://")
180        .trim_start_matches("http://")
181        .trim_end_matches('/')
182}
183
184/// Write a kubeconfig with a static token (no `exec` plugin) pointing at hackamore. When
185/// hackamore terminates TLS, the cluster references the CA bundle by path; otherwise the
186/// endpoint is plaintext and no CA is needed.
187fn 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
208/// Configure `git` and `gh` to use the hackamore token: the store-helper credential line
209/// (merged, not clobbered), a `.gitconfig` enabling that helper (+ CA when TLS), and a `gh`
210/// `hosts.yml` so `gh` authenticates to the hackamore-fronted host.
211fn 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    // 1. git store-helper credential line — merged idempotently so multiple github-flavored
220    //    services accumulate instead of overwriting one another.
221    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    // 2. .gitconfig turning on the store helper (and trusting the CA, when TLS).
227    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    // 3. gh hosts.yml — gh reads the oauth token for this host from here.
234    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
242/// Write an AWS profile (dummy credential + hackamore endpoint) for the `aws` CLI / SDKs:
243/// `~/.aws/credentials` (the dummy key pair) and `~/.aws/config` (region + endpoint, plus
244/// the CA bundle when hackamore terminates TLS).
245fn 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
269/// Merge `line` into the existing newline-separated file at `path` (if any), de-duplicating.
270/// Existing lines are preserved and ordered before the new one; the result ends with a
271/// trailing newline. Idempotent: merging an already-present line is a no-op.
272fn 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
291/// Record the absolute paths hackamore wrote into the manifest (one per line), so [`teardown`]
292/// can later remove exactly them.
293fn 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
303/// Write `contents` to `path`, creating parent directories. Returns `path`.
304fn 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        // No CA → no CA-bundle exports.
387        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        // No TLS → no certificate-authority line.
400        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        // .gitconfig enables the store helper so git actually uses the credential.
410        let gitconfig = std::fs::read_to_string(dir.join(".gitconfig")).unwrap();
411        assert!(gitconfig.contains("helper = store"));
412
413        // gh hosts.yml carries the oauth token for the hackamore host.
414        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        // Everything stayed under the isolated home.
419        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        // A pre-existing, unrelated credential must survive a hackamore write.
456        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        // Writing again does not duplicate.
465        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        // Every written file is gone.
480        for p in &written {
481            assert!(!p.exists(), "{} should be removed", p.display());
482        }
483        assert_eq!(removed.len(), written.len());
484        // The manifest itself is gone, and a second teardown is a no-op.
485        assert!(!dir.join(MANIFEST).exists());
486        assert_eq!(teardown(&dir).unwrap().len(), 0);
487        let _ = std::fs::remove_dir_all(&dir);
488    }
489}