worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Agent-first git hosting. |
| 2 | // |
| 3 | // Boot and operator commands only. Everything else lives in the modules below. |
| 4 | // The product name lives in `brand`; nothing here should spell it out. |
| 5 | |
| 6 | mod account; |
| 7 | mod api; |
| 8 | mod brand; |
| 9 | mod ci; |
| 10 | mod config; |
| 11 | mod control; |
| 12 | mod core; |
| 13 | mod error; |
| 14 | mod git; |
| 15 | mod http; |
| 16 | mod jobs; |
| 17 | mod mcp; |
| 18 | mod setup; |
| 19 | mod ssh; |
| 20 | mod store; |
| 21 | |
| 22 | use account::token; |
| 23 | use account::Scope; |
| 24 | use anyhow::{bail, Context, Result}; |
| 25 | use config::{Config, Mode}; |
| 26 | use hyper::service::service_fn; |
| 27 | use hyper_util::rt::{TokioIo, TokioTimer}; |
| 28 | use std::sync::Arc; |
| 29 | use tokio::net::TcpListener; |
| 30 | |
| 31 | #[tokio::main(flavor = "multi_thread")] |
| 32 | async fn main() -> Result<()> { |
| 33 | match std::env::args().nth(1).as_deref() { |
| 34 | Some("hook") => return run_hook().await, |
| 35 | Some("jobs") => return run_jobs().await, |
| 36 | Some("setup") => return run_setup(), |
| 37 | Some("gc") => return run_maintenance(Job::Gc), |
| 38 | Some("sweep") => return run_maintenance(Job::Sweep), |
| 39 | Some("fsck") => return run_maintenance(Job::Fsck), |
| 40 | Some("token") => return mint_token(), |
| 41 | Some("key") => return add_key(), |
| 42 | Some("version") => { |
| 43 | println!("{} {}", brand::NAME, brand::build()); |
| 44 | return Ok(()); |
| 45 | } |
| 46 | Some(other) => bail!( |
| 47 | "unknown command {other:?} \ |
| 48 | (setup | token | key | jobs | gc | sweep | fsck | hook | version)" |
| 49 | ), |
| 50 | None => {} |
| 51 | } |
| 52 | |
| 53 | serve().await |
| 54 | } |
| 55 | |
| 56 | async fn serve() -> Result<()> { |
| 57 | let config = Config::from_env().context("read configuration")?; |
| 58 | eprintln!( |
| 59 | "[boot] {} {} mode={} data={}", |
| 60 | brand::NAME, |
| 61 | brand::VERSION, |
| 62 | config.mode.as_str(), |
| 63 | config.data_dir.display() |
| 64 | ); |
| 65 | |
| 66 | if config.mode == Mode::Control { |
| 67 | return serve_control(config).await; |
| 68 | } |
| 69 | |
| 70 | // Accepting a security-relevant setting and then ignoring it is worse than not |
| 71 | // accepting it: the operator believes hosted auth is enforcing revocation. |
| 72 | if config.me_url.is_some() { |
| 73 | bail!( |
| 74 | "{} is not implemented yet; unset it to use the local token file", |
| 75 | brand::env_name("ME_URL") |
| 76 | ); |
| 77 | } |
| 78 | if config.mode == Mode::Tenant { |
| 79 | eprintln!( |
| 80 | "[boot] tenant mode: callers are identified by a signed assertion from the \ |
| 81 | control plane ({} key(s) trusted)", |
| 82 | config.control_public_keys.len() |
| 83 | ); |
| 84 | } |
| 85 | if config.import_allow_private { |
| 86 | eprintln!("[boot] import: PRIVATE ADDRESSES ALLOWED — only safe on a trusted network"); |
| 87 | } |
| 88 | if !config.ci_enabled { |
| 89 | eprintln!("[boot] ci: disabled"); |
| 90 | } else { |
| 91 | // Said loudly because standalone CI is not a security boundary: anyone who |
| 92 | // can push can run code as this user. |
| 93 | eprintln!("[boot] ci: ENABLED — steps run as this user with rlimits but no container"); |
| 94 | } |
| 95 | |
| 96 | // Resolved once here rather than per request, where it was a synchronous |
| 97 | // fork+exec on a runtime worker for every clone and push. |
| 98 | git::exec::http_backend()?; |
| 99 | |
| 100 | let bind = config.bind; |
| 101 | let ssh_bind = config.ssh_bind; |
| 102 | let header_timeout = config.header_timeout; |
| 103 | let host_key_path = config.ssh_host_key.clone(); |
| 104 | let state = Arc::new(http::AppState::new(config)?); |
| 105 | |
| 106 | eprintln!( |
| 107 | "[boot] auth: {} token(s), {} ssh key(s)", |
| 108 | state.tokens().tokens.len(), |
| 109 | state.ssh_keys().keys.len() |
| 110 | ); |
| 111 | |
| 112 | if let Some(addr) = ssh_bind { |
| 113 | let host_key = ssh::load_host_key(&host_key_path)?; |
| 114 | let idle = state.config.ssh_idle_timeout; |
| 115 | let server = ssh::SshServer::new(Arc::clone(&state)); |
| 116 | tokio::spawn(async move { |
| 117 | if let Err(e) = server.run(addr, host_key, idle).await { |
| 118 | eprintln!("[ssh] server stopped: {e:#}"); |
| 119 | } |
| 120 | }); |
| 121 | } else { |
| 122 | eprintln!("[boot] ssh: disabled"); |
| 123 | } |
| 124 | |
| 125 | tokio::spawn(ci::serve(Arc::clone(&state))); |
| 126 | |
| 127 | let listener = TcpListener::bind(bind) |
| 128 | .await |
| 129 | .with_context(|| format!("bind {bind}"))?; |
| 130 | eprintln!("[boot] listening on http://{bind}"); |
| 131 | |
| 132 | loop { |
| 133 | let (stream, peer) = listener.accept().await.context("accept")?; |
| 134 | let state = Arc::clone(&state); |
| 135 | tokio::spawn(async move { |
| 136 | let io = TokioIo::new(stream); |
| 137 | let service = service_fn(move |req| http::handle(req, Arc::clone(&state), peer)); |
| 138 | if let Err(e) = hyper::server::conn::http1::Builder::new() |
| 139 | // hyper panics at runtime if a timeout is set without a timer. |
| 140 | .timer(TokioTimer::new()) |
| 141 | .header_read_timeout(header_timeout) |
| 142 | .serve_connection(io, service) |
| 143 | .await |
| 144 | { |
| 145 | // A client hanging up mid-clone is ordinary, not a fault. |
| 146 | let message = e.to_string(); |
| 147 | if !message.contains("closed") && !message.contains("reset") { |
| 148 | eprintln!("[conn] {message}"); |
| 149 | } |
| 150 | } |
| 151 | }); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// `<binary> setup --standalone|--isolated` — install onto this host. |
| 156 | fn run_setup() -> Result<()> { |
| 157 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 158 | let options = setup::Options::from_args(&args)?; |
| 159 | |
| 160 | let checks = setup::preflight(options.shape); |
| 161 | let missing: Vec<&(String, bool, String)> = checks.iter().filter(|(_, ok, _)| !ok).collect(); |
| 162 | |
| 163 | for (name, ok, detail) in &checks { |
| 164 | eprintln!( |
| 165 | "[setup] {} {name}: {detail}", |
| 166 | if *ok { "ok " } else { "MISS" } |
| 167 | ); |
| 168 | } |
| 169 | if !missing.is_empty() && !options.dry_run { |
| 170 | bail!( |
| 171 | "missing prerequisites: {}. Install them, or re-run with --dry-run to see \ |
| 172 | what would be written.", |
| 173 | missing |
| 174 | .iter() |
| 175 | .map(|(n, _, _)| n.as_str()) |
| 176 | .collect::<Vec<_>>() |
| 177 | .join(", ") |
| 178 | ); |
| 179 | } |
| 180 | eprintln!(); |
| 181 | |
| 182 | setup::apply(&options) |
| 183 | } |
| 184 | |
| 185 | /// The control plane: accounts, provisioning, and proxying to tenants. |
| 186 | /// |
| 187 | /// Holds no repositories. Every data-plane request is forwarded to the account's own |
| 188 | /// container with a freshly signed, single-use assertion. |
| 189 | async fn serve_control(config: Config) -> Result<()> { |
| 190 | use control::provision::{IncusProvisioner, Provisioner}; |
| 191 | |
| 192 | let accounts = control::accounts::AccountStore::open(config.accounts_dir())?; |
| 193 | let signer = load_control_key(&config)?; |
| 194 | eprintln!( |
| 195 | "[control] tenants need {}={}", |
| 196 | brand::env_name("CONTROL_PUBLIC_KEYS"), |
| 197 | signer.public_key() |
| 198 | ); |
| 199 | |
| 200 | let provisioner: Arc<dyn Provisioner> = Arc::new(IncusProvisioner::new( |
| 201 | config.incus_image.clone(), |
| 202 | config.incus_network.clone(), |
| 203 | config.tenant_port, |
| 204 | config.tenant_binary.clone(), |
| 205 | signer.public_key(), |
| 206 | std::env::var(brand::env_name("PUBLIC_URL")).ok(), |
| 207 | std::env::var(brand::env_name("PUBLIC_SSH")).ok(), |
| 208 | )); |
| 209 | |
| 210 | tokio::spawn(control::reconcile_forever( |
| 211 | accounts.clone(), |
| 212 | Arc::clone(&provisioner), |
| 213 | )); |
| 214 | |
| 215 | control::serve(config, accounts, signer, provisioner).await |
| 216 | } |
| 217 | |
| 218 | /// Load the control plane's signing key, generating one on first boot. |
| 219 | /// |
| 220 | /// The private half never leaves this host: a tenant runs the account's own CI, so |
| 221 | /// anything a tenant holds must be forgery-proof. |
| 222 | fn load_control_key(config: &Config) -> Result<account::assertion::Signer_> { |
| 223 | use base64::Engine; |
| 224 | |
| 225 | let path = &config.control_key_file; |
| 226 | if path.exists() { |
| 227 | let encoded = |
| 228 | std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; |
| 229 | let bytes = base64::engine::general_purpose::STANDARD |
| 230 | .decode(encoded.trim()) |
| 231 | .context("control key is not valid base64")?; |
| 232 | let seed: [u8; 32] = bytes |
| 233 | .try_into() |
| 234 | .map_err(|_| anyhow::anyhow!("control key must be 32 bytes"))?; |
| 235 | return Ok(account::assertion::Signer_::from_seed(&seed)); |
| 236 | } |
| 237 | |
| 238 | let mut seed = [0u8; 32]; |
| 239 | getrandom::fill(&mut seed).map_err(|e| anyhow::anyhow!("read OS entropy: {e}"))?; |
| 240 | account::token::write_private( |
| 241 | path, |
| 242 | base64::engine::general_purpose::STANDARD |
| 243 | .encode(seed) |
| 244 | .as_bytes(), |
| 245 | )?; |
| 246 | eprintln!("[control] generated a signing key at {}", path.display()); |
| 247 | Ok(account::assertion::Signer_::from_seed(&seed)) |
| 248 | } |
| 249 | |
| 250 | /// `post-receive` — queue a CI run for each updated ref. |
| 251 | /// |
| 252 | /// git feeds `<old> <new> <ref>` per line on stdin. This never fails the push: a |
| 253 | /// broken queue must not stop code being accepted. |
| 254 | async fn enqueue_runs() -> Result<()> { |
| 255 | use std::io::Read; |
| 256 | |
| 257 | let config = Config::from_env().context("read configuration")?; |
| 258 | if !config.ci_enabled { |
| 259 | return Ok(()); |
| 260 | } |
| 261 | |
| 262 | let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".".into()); |
| 263 | let Some((account, repo)) = account_and_repo(&config, &git_dir) else { |
| 264 | return Ok(()); |
| 265 | }; |
| 266 | |
| 267 | let mut stdin = String::new(); |
| 268 | if std::io::stdin().read_to_string(&mut stdin).is_err() { |
| 269 | return Ok(()); |
| 270 | } |
| 271 | |
| 272 | let runs = match ci::run::RunStore::open(config.runs_dir()) { |
| 273 | Ok(runs) => runs, |
| 274 | Err(e) => { |
| 275 | eprintln!("{}", brand::say(format_args!("could not queue a run: {e}"))); |
| 276 | return Ok(()); |
| 277 | } |
| 278 | }; |
| 279 | |
| 280 | for line in stdin.lines() { |
| 281 | let fields: Vec<&str> = line.split_whitespace().collect(); |
| 282 | let [_old, new, git_ref] = fields.as_slice() else { |
| 283 | continue; |
| 284 | }; |
| 285 | // A deletion has nothing to build. |
| 286 | if git::store::is_null_oid(new) { |
| 287 | continue; |
| 288 | } |
| 289 | |
| 290 | let run = ci::run::Run::queued(account.as_str(), repo.as_str(), git_ref, new); |
| 291 | match runs.put(&account, &repo, &run) { |
| 292 | Ok(()) => println!("{}", brand::say(format_args!("queued run {}", run.id))), |
| 293 | Err(e) => eprintln!("{}", brand::say(format_args!("could not queue a run: {e}"))), |
| 294 | } |
| 295 | } |
| 296 | Ok(()) |
| 297 | } |
| 298 | |
| 299 | /// Recover `(account, repo)` from a bare repository path under the git root. |
| 300 | fn account_and_repo( |
| 301 | config: &Config, |
| 302 | git_dir: &str, |
| 303 | ) -> Option<(git::validate::Name, git::validate::Name)> { |
| 304 | let path = std::path::Path::new(git_dir).canonicalize().ok()?; |
| 305 | let root = config.data_dir.join("git").canonicalize().ok()?; |
| 306 | let rest = path.strip_prefix(&root).ok()?; |
| 307 | |
| 308 | let mut parts = rest.components(); |
| 309 | let account = parts.next()?.as_os_str().to_str()?; |
| 310 | let repo = parts.next()?.as_os_str().to_str()?.strip_suffix(".git")?; |
| 311 | |
| 312 | Some(( |
| 313 | git::validate::name(account).ok()?, |
| 314 | git::validate::name(repo).ok()?, |
| 315 | )) |
| 316 | } |
| 317 | |
| 318 | /// One-shot maintenance, for an operator or a timer. |
| 319 | #[derive(Debug, Clone, Copy)] |
| 320 | enum Job { |
| 321 | Gc, |
| 322 | Sweep, |
| 323 | Fsck, |
| 324 | } |
| 325 | |
| 326 | /// `<binary> gc | sweep | fsck [--repair]` |
| 327 | fn run_maintenance(job: Job) -> Result<()> { |
| 328 | let config = Config::from_env().context("read configuration")?; |
| 329 | |
| 330 | match job { |
| 331 | Job::Gc => { |
| 332 | let outcome = jobs::gc::run(&config); |
| 333 | eprintln!( |
| 334 | "[gc] repacked {} repositories, reclaimed {} bytes", |
| 335 | outcome.packed, outcome.reclaimed |
| 336 | ); |
| 337 | } |
| 338 | Job::Sweep => { |
| 339 | let outcome = jobs::sweep::run(&config); |
| 340 | eprintln!( |
| 341 | "[sweep] removed {} deleted repositories, reclaimed {} bytes", |
| 342 | outcome.removed, outcome.bytes |
| 343 | ); |
| 344 | } |
| 345 | Job::Fsck => { |
| 346 | let repair = std::env::args().any(|a| a == "--repair"); |
| 347 | let outcome = jobs::fsck::run(&config, repair); |
| 348 | println!("{}", serde_json::to_string_pretty(&outcome)?); |
| 349 | eprintln!( |
| 350 | "[fsck] checked {} repositories, {} orphan(s), {} repaired", |
| 351 | outcome.checked, |
| 352 | outcome.orphans.len(), |
| 353 | outcome.repaired |
| 354 | ); |
| 355 | // A non-zero exit lets a timer or a health check notice. |
| 356 | if !outcome.orphans.is_empty() && !repair { |
| 357 | std::process::exit(1); |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | Ok(()) |
| 362 | } |
| 363 | |
| 364 | /// `<binary> jobs` — the maintenance daemon, for its own service unit. |
| 365 | async fn run_jobs() -> Result<()> { |
| 366 | let config = Config::from_env().context("read configuration")?; |
| 367 | eprintln!( |
| 368 | "[boot] {} {} jobs data={}", |
| 369 | brand::NAME, |
| 370 | brand::VERSION, |
| 371 | config.data_dir.display() |
| 372 | ); |
| 373 | jobs::serve(Arc::new(config)).await; |
| 374 | Ok(()) |
| 375 | } |
| 376 | |
| 377 | /// `<binary> hook <name> [args...]` — invoked by the installed git hooks. |
| 378 | /// |
| 379 | /// The hooks carry no policy; each execs back into this binary so the rule has one |
| 380 | /// implementation shared with the API path. |
| 381 | async fn run_hook() -> Result<()> { |
| 382 | let name = std::env::args() |
| 383 | .nth(2) |
| 384 | .with_context(|| format!("usage: {} hook <name>", brand::NAME))?; |
| 385 | |
| 386 | match name.as_str() { |
| 387 | "update" => { |
| 388 | // git calls: update <ref> <old> <new> |
| 389 | let args: Vec<String> = std::env::args().skip(3).collect(); |
| 390 | let [ref_name, old, new] = args.as_slice() else { |
| 391 | bail!("update hook expects <ref> <old> <new>"); |
| 392 | }; |
| 393 | |
| 394 | let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".".into()); |
| 395 | |
| 396 | match git::store::check_ref_move(std::path::Path::new(&git_dir), ref_name, old, new) { |
| 397 | Ok(()) => Ok(()), |
| 398 | Err(e) => { |
| 399 | // Printed to the pusher's terminal by receive-pack. |
| 400 | eprintln!("{}", brand::say(e.detail_for_user())); |
| 401 | std::process::exit(1); |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | "post-receive" => enqueue_runs().await, |
| 406 | other => bail!("unknown hook {other:?}"), |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | /// `<binary> token <account> [--name n] [--scopes a,b] [--repos a,b] [--days n]` |
| 411 | fn mint_token() -> Result<()> { |
| 412 | let config = Config::from_env().context("read configuration")?; |
| 413 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 414 | |
| 415 | let account = args |
| 416 | .first() |
| 417 | .filter(|a| !a.starts_with("--")) |
| 418 | .with_context(|| { |
| 419 | format!( |
| 420 | "usage: {} token <account> [--scopes admin] [--days 90]", |
| 421 | brand::NAME |
| 422 | ) |
| 423 | })? |
| 424 | .clone(); |
| 425 | let account = git::validate::name(&account)?; |
| 426 | |
| 427 | let name = flag(&args, "--name").unwrap_or_else(|| "default".into()); |
| 428 | let scopes = parse_scopes(&flag(&args, "--scopes").unwrap_or_else(|| "admin".into()))?; |
| 429 | let repos = flag_repos(&args)?; |
| 430 | |
| 431 | // A forever-token is total account compromise, so expiry is opt-out and loud. |
| 432 | let expires_at = if args.iter().any(|a| a == "--no-expiry") { |
| 433 | eprintln!("[token] warning: this token never expires"); |
| 434 | None |
| 435 | } else { |
| 436 | let days = flag(&args, "--days") |
| 437 | .map(|v| v.parse::<u64>().context("--days must be a number")) |
| 438 | .transpose()? |
| 439 | .unwrap_or(config.token_ttl_days); |
| 440 | Some(token::now_secs() + days * 24 * 60 * 60) |
| 441 | }; |
| 442 | |
| 443 | let (secret, record) = token::mint(account.as_str(), &name, scopes, repos, expires_at); |
| 444 | |
| 445 | let path = config.tokens_file(); |
| 446 | let mut file = token::TokenFile::load(&path)?; |
| 447 | file.tokens.push(record); |
| 448 | file.save(&path)?; |
| 449 | |
| 450 | eprintln!("[token] wrote {}", path.display()); |
| 451 | eprintln!("[token] shown once and not recoverable:"); |
| 452 | println!("{secret}"); |
| 453 | Ok(()) |
| 454 | } |
| 455 | |
| 456 | /// `<binary> key <account> <path-to-pubkey> [--title t] [--read-only] [--repos a,b]` |
| 457 | fn add_key() -> Result<()> { |
| 458 | let config = Config::from_env().context("read configuration")?; |
| 459 | let args: Vec<String> = std::env::args().skip(2).collect(); |
| 460 | |
| 461 | let account = args |
| 462 | .first() |
| 463 | .with_context(|| format!("usage: {} key <account> <path-to-public-key>", brand::NAME))?; |
| 464 | let account = git::validate::name(account)?; |
| 465 | |
| 466 | let key_path = args |
| 467 | .get(1) |
| 468 | .filter(|a| !a.starts_with("--")) |
| 469 | .with_context(|| format!("usage: {} key <account> <path-to-public-key>", brand::NAME))?; |
| 470 | let line = std::fs::read_to_string(key_path).with_context(|| format!("read {key_path}"))?; |
| 471 | |
| 472 | let (algorithm, blob) = account::key::parse_authorized_key(&line)?; |
| 473 | |
| 474 | let repos = flag_repos(&args)?; |
| 475 | |
| 476 | let record = account::KeyRecord { |
| 477 | id: uuid::Uuid::new_v4().simple().to_string(), |
| 478 | account: account.as_str().to_string(), |
| 479 | title: flag(&args, "--title").unwrap_or_else(|| "cli".into()), |
| 480 | public_key: account::key::encode_public_key(&algorithm, &blob), |
| 481 | fingerprint: account::key::fingerprint(&blob), |
| 482 | read_only: args.iter().any(|a| a == "--read-only"), |
| 483 | repos, |
| 484 | created_at: token::now_secs(), |
| 485 | last_used_at: None, |
| 486 | }; |
| 487 | |
| 488 | let path = config.keys_file(); |
| 489 | let mut file = account::KeyFile::load(&path)?; |
| 490 | if file.keys.iter().any(|k| k.public_key == record.public_key) { |
| 491 | bail!("that public key is already registered"); |
| 492 | } |
| 493 | eprintln!("[key] {} -> {}", record.fingerprint, record.account); |
| 494 | file.keys.push(record); |
| 495 | file.save(&path)?; |
| 496 | eprintln!("[key] wrote {}", path.display()); |
| 497 | Ok(()) |
| 498 | } |
| 499 | |
| 500 | /// A comma-separated `--repos` list, validated into canonical names. |
| 501 | fn flag_repos(args: &[String]) -> Result<Vec<String>> { |
| 502 | let Some(raw) = flag(args, "--repos") else { |
| 503 | return Ok(Vec::new()); |
| 504 | }; |
| 505 | raw.split(',') |
| 506 | .filter(|s| !s.is_empty()) |
| 507 | .map(|s| Ok(git::validate::name(s)?.as_str().to_string())) |
| 508 | .collect() |
| 509 | } |
| 510 | |
| 511 | fn flag(args: &[String], name: &str) -> Option<String> { |
| 512 | args.iter() |
| 513 | .position(|a| a == name) |
| 514 | .and_then(|i| args.get(i + 1)) |
| 515 | .cloned() |
| 516 | } |
| 517 | |
| 518 | fn parse_scopes(raw: &str) -> Result<Vec<Scope>> { |
| 519 | let scopes = raw |
| 520 | .split(',') |
| 521 | .filter(|s| !s.is_empty()) |
| 522 | .map(|s| match s.trim() { |
| 523 | "repo:read" => Ok(Scope::RepoRead), |
| 524 | "repo:write" => Ok(Scope::RepoWrite), |
| 525 | "admin" => Ok(Scope::Admin), |
| 526 | "ci" => Ok(Scope::Ci), |
| 527 | other => bail!("unknown scope {other:?} (repo:read | repo:write | admin | ci)"), |
| 528 | }) |
| 529 | .collect::<Result<Vec<_>>>()?; |
| 530 | |
| 531 | if scopes.is_empty() { |
| 532 | bail!("at least one scope is required"); |
| 533 | } |
| 534 | Ok(scopes) |
| 535 | } |