pub enum JoinDecision {
Approved(String),
Failed(String),
}
pub enum MintDecision {
Minted { token: String, expires_at: String },
Failed(String),
}
pub fn valid_join_token(t: &str) -> bool {
match t.strip_prefix("zj_") {
Some(rest) => {
rest.len() == 32
&& rest
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
None => false,
}
}
pub fn decide_join(status: u16, body: &str) -> JoinDecision {
match status {
200 => match serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v["api_key"].as_str().map(|s| s.to_string()))
{
Some(k) => JoinDecision::Approved(k),
None => JoinDecision::Failed("joined but no api_key in response".into()),
},
410 => JoinDecision::Failed(
"token expired or already used — ask the owner to run `zc token create` again".into(),
),
404 => JoinDecision::Failed("unknown token".into()),
400 => JoinDecision::Failed("malformed token".into()),
s => JoinDecision::Failed(format!("unexpected status {s}")),
}
}
pub fn decide_mint(status: u16, body: &str) -> MintDecision {
match status {
200 => {
let v: Option<serde_json::Value> = serde_json::from_str(body).ok();
let token = v
.as_ref()
.and_then(|v| v["join_token"].as_str())
.map(|s| s.to_string());
let expires_at = v
.as_ref()
.and_then(|v| v["expires_at"].as_str())
.map(|s| s.to_string());
match (token, expires_at) {
(Some(token), Some(expires_at)) => MintDecision::Minted { token, expires_at },
_ => MintDecision::Failed("minted but response missing fields".into()),
}
}
401 | 403 => MintDecision::Failed(
"not authorized — sign in first (`zc init`) or set ZAKURO_API_KEY".into(),
),
s => MintDecision::Failed(format!("unexpected status {s}")),
}
}
fn api_url() -> String {
crate::credentials::default_api_url()
}
fn post_json(url: &str, api_key: Option<&str>, payload: &str) -> Result<(u16, String), String> {
let mut req = ureq::post(url)
.config()
.http_status_as_error(false)
.build()
.header("Content-Type", "application/json");
if let Some(k) = api_key {
req = req.header("X-Broker-Api-Key", k);
}
match req.send(payload) {
Ok(r) => {
let s = r.status().as_u16();
let b = r.into_body().read_to_string().unwrap_or_default();
Ok((s, b))
}
Err(e) => Err(e.to_string()),
}
}
pub fn run_token_create(args: &[String]) -> i32 {
crate::credentials::load_into_env();
let api_key = match std::env::var("ZAKURO_API_KEY") {
Ok(k) => k,
Err(_) => {
eprintln!("Not signed in. Run `zc init` or set ZAKURO_API_KEY.");
return 1;
}
};
let mut ttl: Option<u64> = None;
let mut label: Option<String> = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--ttl" => ttl = it.next().and_then(|v| v.parse().ok()),
"--label" => label = it.next().cloned(),
_ => {}
}
}
let mut body = serde_json::Map::new();
if let Some(t) = ttl {
body.insert("ttl".into(), t.into());
}
if let Some(l) = label {
body.insert("label".into(), l.into());
}
let url = format!("{}/api/auth/cli/token", api_url().trim_end_matches('/'));
let payload = serde_json::Value::Object(body).to_string();
match post_json(&url, Some(&api_key), &payload) {
Ok((status, resp)) => match decide_mint(status, &resp) {
MintDecision::Minted { token, expires_at } => {
println!("{token}");
eprintln!("expires: {expires_at}");
eprintln!("On the new node, run: zc join {token}");
0
}
MintDecision::Failed(msg) => {
eprintln!("{msg}");
1
}
},
Err(e) => {
eprintln!("Could not reach {}: {e}", api_url());
1
}
}
}
pub fn run_join(args: &[String]) -> i32 {
let force = args.iter().any(|a| a == "--force");
let mut api_url_flag: Option<String> = None;
let mut token: Option<String> = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--api-url" => api_url_flag = it.next().cloned(),
"--force" => {}
t if t.starts_with("zj_") => token = Some(t.to_string()),
_ => {}
}
}
crate::credentials::load_into_env();
if std::env::var("ZAKURO_API_KEY").is_ok() && !force {
eprintln!("Already signed in. Use `zc join <token> --force` to re-enroll.");
return 0;
}
let token = match token {
Some(t) if valid_join_token(&t) => t,
Some(_) => {
eprintln!("Invalid token — expected zj_<32 hex chars>.");
return 1;
}
None => {
eprintln!("Usage: zc join <zj_token> [--api-url URL] [--force]");
return 1;
}
};
let base = api_url_flag.unwrap_or_else(api_url);
let url = format!("{}/api/auth/cli/join", base.trim_end_matches('/'));
let payload = serde_json::json!({ "join_token": token }).to_string();
match post_json(&url, None, &payload) {
Ok((status, resp)) => match decide_join(status, &resp) {
JoinDecision::Approved(key) => {
if let Err(e) = crate::credentials::save(&key, Some(&base)) {
eprintln!("joined but could not write credentials: {e}");
return 1;
}
println!("✓ Enrolled. Key saved to ~/.zakuro/credentials.");
println!("Next: `zc vpn conf` to get the mesh config, then `zc up`.");
0
}
JoinDecision::Failed(msg) => {
eprintln!("{msg}");
1
}
},
Err(e) => {
eprintln!("Could not reach {base}: {e}");
eprintln!("Fallback: export ZAKURO_API_KEY=<key from the dashboard>");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_token_format() {
assert!(valid_join_token("zj_0123456789abcdef0123456789abcdef"));
assert!(!valid_join_token("zj_SHORT"));
assert!(!valid_join_token("zk_0123456789abcdef0123456789abcdef"));
assert!(!valid_join_token("zj_0123456789ABCDEF0123456789ABCDEF"));
}
#[test]
fn decide_join_maps_statuses() {
assert!(matches!(
decide_join(200, r#"{"api_key":"zk_1_abc"}"#),
JoinDecision::Approved(ref k) if k == "zk_1_abc"
));
assert!(matches!(decide_join(200, "{}"), JoinDecision::Failed(_)));
assert!(matches!(decide_join(410, ""), JoinDecision::Failed(ref m)
if m.contains("expired or already used")));
assert!(matches!(decide_join(404, ""), JoinDecision::Failed(ref m)
if m.contains("unknown")));
assert!(matches!(decide_join(500, ""), JoinDecision::Failed(_)));
}
#[test]
fn decide_mint_maps_statuses() {
assert!(matches!(
decide_mint(200, r#"{"join_token":"zj_0123456789abcdef0123456789abcdef","expires_at":"2026-07-06T12:00:00Z"}"#),
MintDecision::Minted { ref token, ref expires_at }
if token == "zj_0123456789abcdef0123456789abcdef" && expires_at == "2026-07-06T12:00:00Z"
));
assert!(matches!(decide_mint(200, "{}"), MintDecision::Failed(_)));
assert!(matches!(decide_mint(401, ""), MintDecision::Failed(ref m)
if m.contains("not authorized")));
assert!(matches!(decide_mint(500, ""), MintDecision::Failed(_)));
}
use std::io::{Read, Write};
use std::net::TcpListener;
fn mock_server(status: u16, body: &'static str) -> (std::thread::JoinHandle<String>, u16) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let h = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut raw = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
raw.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&raw);
if let Some(hdr_end) = text.find("\r\n\r\n") {
let want: usize = text
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse().unwrap_or(0))
})
.unwrap_or(0);
if raw.len() >= hdr_end + 4 + want {
break;
}
}
}
let req = String::from_utf8_lossy(&raw).to_string();
let resp = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = s.write_all(resp.as_bytes());
req
});
(h, port)
}
#[test]
fn join_end_to_end_writes_credentials() {
let _env = crate::credentials::HOME_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("zc-enroll-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let old_home = std::env::var_os("HOME");
let old_zh = std::env::var_os("ZAKURO_HOME");
std::env::remove_var("ZAKURO_HOME");
std::env::set_var("HOME", &tmp);
std::env::remove_var("ZAKURO_API_KEY");
let (h, port) = mock_server(200, r#"{"api_key":"zk_42_deadbeef"}"#);
let code = run_join(&[
"zj_0123456789abcdef0123456789abcdef".to_string(),
"--api-url".to_string(),
format!("http://127.0.0.1:{port}"),
"--force".to_string(),
]);
let req = h.join().unwrap();
assert_eq!(code, 0);
assert!(req.starts_with("POST /api/auth/cli/join"));
assert!(req.contains("zj_0123456789abcdef0123456789abcdef"));
let saved = std::fs::read_to_string(tmp.join(".zakuro/credentials")).unwrap();
assert!(saved.contains("api_key=zk_42_deadbeef"));
assert!(saved.contains(&format!("api_url=http://127.0.0.1:{port}")));
match old_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if let Some(v) = old_zh {
std::env::set_var("ZAKURO_HOME", v);
}
let _ = std::fs::remove_dir_all(&tmp);
}
}