zuka
zuka/src/setup.rs

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
zuka/src/setup.rs
RSsetup.rs22.4 KBDownload
1// `<binary> setup` — turn a bare Linux host into a running service.
2//
3// This exists because the gap between "the binary works" and "the service is
4// running behind TLS with maintenance scheduled" is a page of instructions that
5// everyone follows slightly differently. It writes the units, the Caddyfile and the
6// data directory, and prints the one credential you need.
7//
8// Two shapes:
9//
10// --standalone one process, repositories on local disk. What a self-hoster wants.
11// --isolated a control plane plus one Incus container per account. Every
12// account's code and CI runs in its own container, and the control
13// plane holds no repositories.
14//
15// Everything it writes is printed first with `--dry-run`, because a setup command
16// that silently rewrites files on a host is not something to run twice.
17
18use crate::brand;
19use anyhow::{bail, Context, Result};
20use std::path::{Path, PathBuf};
21
22/// Which shape to install.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Shape {
25 Standalone,
26 Isolated,
27}
28
29impl Shape {
30 fn as_str(self) -> &'static str {
31 match self {
32 Shape::Standalone => "standalone",
33 Shape::Isolated => "isolated",
34 }
35 }
36}
37
38pub struct Options {
39 pub shape: Shape,
40 /// Names the unit, the environment file and the default data directory.
41 ///
42 /// Defaults to the product name. A second instance on the same host needs a
43 /// different one — which is not a hypothetical: self-hosting requires a build
44 /// instance separate from the control plane, because a deploy restarts the
45 /// control plane and a build running inside it would kill itself half way.
46 pub instance: String,
47 /// Public hostname. TLS is only requested when this is a real domain.
48 pub domain: Option<String>,
49 /// Unix user the service runs as.
50 pub user: String,
51 pub data_dir: PathBuf,
52 pub bind: String,
53 pub ssh_bind: String,
54 pub ci_enabled: bool,
55 pub dry_run: bool,
56}
57
58impl Options {
59 pub fn from_args(args: &[String]) -> Result<Self> {
60 let shape = if args.iter().any(|a| a == "--isolated") {
61 Shape::Isolated
62 } else if args.iter().any(|a| a == "--standalone") {
63 Shape::Standalone
64 } else {
65 bail!(
66 "choose a shape: --standalone (one host, local repositories) or \
67 --isolated (a container per account)"
68 );
69 };
70
71 let flag = |name: &str| -> Option<String> {
72 args.iter()
73 .position(|a| a == name)
74 .and_then(|i| args.get(i + 1))
75 .cloned()
76 };
77
78 let instance = flag("--instance").unwrap_or_else(|| brand::NAME.to_string());
79 // This becomes a path under /etc and a unit name. Anything outside this set
80 // would either escape the directory or produce a unit systemd will not load.
81 if instance.is_empty()
82 || !instance
83 .chars()
84 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
85 {
86 bail!("--instance must be lowercase letters, digits and dashes");
87 }
88
89 let data_dir = flag("--data-dir")
90 .map(PathBuf::from)
91 .unwrap_or_else(|| PathBuf::from(format!("/var/lib/{instance}")));
92 if data_dir.is_relative() {
93 bail!("--data-dir must be absolute");
94 }
95
96 Ok(Options {
97 shape,
98 domain: flag("--domain"),
99 user: flag("--user").unwrap_or_else(|| instance.clone()),
100 instance,
101 data_dir,
102 bind: flag("--bind").unwrap_or_else(|| "127.0.0.1:8790".into()),
103 ssh_bind: flag("--ssh-bind").unwrap_or_else(|| "0.0.0.0:2222".into()),
104 ci_enabled: args.iter().any(|a| a == "--ci"),
105 dry_run: args.iter().any(|a| a == "--dry-run"),
106 })
107 }
108}
109
110/// A file the setup will write.
111pub struct Artifact {
112 pub path: PathBuf,
113 pub contents: String,
114 /// `0600` for anything holding a credential.
115 pub mode: u32,
116}
117
118/// Everything `setup` would write, without writing it.
119///
120/// Separated from execution so the plan can be printed, diffed and unit-tested —
121/// the interesting failures here are "wrong unit file", not "write failed".
122pub fn plan(options: &Options) -> Vec<Artifact> {
123 let name = options.instance.as_str();
124 let binary = std::env::current_exe()
125 .map(|p| p.display().to_string())
126 .unwrap_or_else(|_| format!("/usr/local/bin/{name}"));
127
128 let mut artifacts = vec![
129 Artifact {
130 path: PathBuf::from(format!("/etc/{name}.env")),
131 contents: environment(options),
132 // Holds no secret today, but it is the file an operator adds one to.
133 mode: 0o600,
134 },
135 Artifact {
136 path: PathBuf::from(format!("/etc/systemd/system/{name}.service")),
137 contents: service_unit(options, &binary),
138 mode: 0o644,
139 },
140 Artifact {
141 path: PathBuf::from(format!("/etc/systemd/system/{name}-jobs.service")),
142 contents: jobs_unit(options, &binary),
143 mode: 0o644,
144 },
145 ];
146
147 if let Some(domain) = &options.domain {
148 artifacts.push(Artifact {
149 path: PathBuf::from("/etc/caddy/Caddyfile"),
150 contents: caddyfile(domain, &options.bind, &options.instance),
151 mode: 0o644,
152 });
153 }
154
155 artifacts
156}
157
158/// The environment file both units read.
159fn environment(options: &Options) -> String {
160 let p = brand::env_prefix();
161 let mut lines = vec![
162 format!(
163 "# Written by `{} setup --{}`.",
164 brand::NAME,
165 options.shape.as_str()
166 ),
167 format!("{p}DATA_DIR={}", options.data_dir.display()),
168 format!("{p}BIND={}", options.bind),
169 format!("{p}SSH_BIND={}", options.ssh_bind),
170 ];
171
172 if let Some(domain) = &options.domain {
173 lines.push(format!("{p}PUBLIC_URL=https://{domain}"));
174 // The SSH port is not proxied by Caddy: git over SSH is not HTTP.
175 let port = options.ssh_bind.rsplit(':').next().unwrap_or("2222");
176 lines.push(format!("{p}PUBLIC_SSH=ssh://git@{domain}:{port}"));
177 }
178
179 match options.shape {
180 Shape::Standalone => {
181 lines.push(format!("{p}MODE=standalone"));
182 lines.push(format!(
183 "{p}CI_ENABLED={}",
184 if options.ci_enabled { "1" } else { "0" }
185 ));
186 if options.ci_enabled {
187 lines.push("# CI runs as this unit's user with rlimits but no container.".into());
188 lines.push("# Anyone who can push can run code as that user.".into());
189 }
190 }
191 Shape::Isolated => {
192 lines.push(format!("{p}MODE=control"));
193 lines.push("# The control plane holds no repositories; each account gets".into());
194 lines.push("# its own Incus container, and CI runs inside it.".into());
195 lines.push(format!("{p}INCUS_IMAGE=images:debian/12"));
196 lines.push(format!("{p}INCUS_NETWORK=incusbr0"));
197 }
198 }
199 lines.join("\n") + "\n"
200}
201
202fn service_unit(options: &Options, binary: &str) -> String {
203 let name = options.instance.as_str();
204 // The control plane drives Incus, which needs a socket only root or the
205 // incus-admin group can reach.
206 let extra = match options.shape {
207 Shape::Isolated => "SupplementaryGroups=incus-admin\n",
208 Shape::Standalone => "",
209 };
210
211 format!(
212 "[Unit]\n\
213 Description={product} git hosting ({shape}, instance {name})\n\
214 After=network-online.target\n\
215 Wants=network-online.target\n\
216 \n\
217 [Service]\n\
218 ExecStart={binary}\n\
219 EnvironmentFile=/etc/{name}.env\n\
220 User={user}\n\
221 Restart=always\n\
222 RestartSec=2\n\
223 # Repositories and credentials live under the data directory and nowhere else.\n\
224 ReadWritePaths={data}\n\
225 ProtectSystem=strict\n\
226 ProtectHome=yes\n\
227 PrivateTmp=yes\n\
228 NoNewPrivileges=yes\n\
229 {extra}\
230 \n\
231 [Install]\n\
232 WantedBy=multi-user.target\n",
233 name = name,
234 product = brand::NAME,
235 shape = options.shape.as_str(),
236 binary = binary,
237 user = options.user,
238 data = options.data_dir.display(),
239 extra = extra,
240 )
241}
242
243fn jobs_unit(options: &Options, binary: &str) -> String {
244 let name = options.instance.as_str();
245 format!(
246 "[Unit]\n\
247 Description={name} maintenance\n\
248 After={name}.service\n\
249 \n\
250 [Service]\n\
251 # Separate from the web unit: a long repack must not stall request handling.\n\
252 ExecStart={binary} jobs\n\
253 EnvironmentFile=/etc/{name}.env\n\
254 User={user}\n\
255 Restart=always\n\
256 RestartSec=10\n\
257 ReadWritePaths={data}\n\
258 ProtectSystem=strict\n\
259 ProtectHome=yes\n\
260 NoNewPrivileges=yes\n\
261 \n\
262 [Install]\n\
263 WantedBy=multi-user.target\n",
264 name = name,
265 binary = binary,
266 user = options.user,
267 data = options.data_dir.display(),
268 )
269}
270
271/// Caddy in front, for TLS.
272///
273/// Caddy rather than nginx because it obtains and renews certificates without a
274/// second tool and a cron entry, which is the part self-hosters get wrong.
275fn caddyfile(domain: &str, upstream: &str, instance: &str) -> String {
276 format!(
277 "# Written by `{product} setup`. Caddy obtains and renews TLS automatically.\n\
278 {domain} {{\n\
279 \treverse_proxy {upstream} {{\n\
280 \t\t# Git streams: a clone can run for minutes and a push is one long\n\
281 \t\t# request. Buffering either would undo the streaming the service does.\n\
282 \t\tflush_interval -1\n\
283 \t}}\n\
284 \n\
285 \t# A push is a large body. Caddy does not cap it by default; this is the\n\
286 \t# ceiling the service itself enforces, stated here so the proxy agrees.\n\
287 \trequest_body {{\n\
288 \t\tmax_size 512MB\n\
289 \t}}\n\
290 \n\
291 \tencode zstd gzip\n\
292 \n\
293 \tlog {{\n\
294 \t\toutput file /var/log/caddy/{name}.log\n\
295 \t}}\n\
296 }}\n",
297 product = brand::NAME,
298 name = instance,
299 domain = domain,
300 upstream = upstream,
301 )
302}
303
304/// Shell commands the operator still has to run, in order.
305///
306/// Printed rather than executed: creating users, enabling units and initialising
307/// Incus are the steps someone may reasonably want to do differently, and a setup
308/// command that does them silently is hard to undo.
309pub fn next_steps(options: &Options) -> Vec<String> {
310 let name = options.instance.as_str();
311 let mut steps = vec![
312 format!(
313 "sudo useradd --system --home {} --shell /usr/sbin/nologin {} 2>/dev/null || true",
314 options.data_dir.display(),
315 options.user
316 ),
317 format!(
318 "sudo mkdir -p {} && sudo chown -R {}:{} {}",
319 options.data_dir.display(),
320 options.user,
321 options.user,
322 options.data_dir.display()
323 ),
324 ];
325
326 if options.shape == Shape::Isolated {
327 steps.push("sudo apt-get install -y incus".into());
328 steps.push("sudo incus admin init --auto".into());
329 steps.push(format!("sudo usermod -aG incus-admin {}", options.user));
330 }
331
332 steps.push("sudo systemctl daemon-reload".into());
333 steps.push(format!("sudo systemctl enable --now {name} {name}-jobs"));
334
335 if options.domain.is_some() {
336 steps.push("sudo apt-get install -y caddy".into());
337 steps.push("sudo systemctl reload caddy".into());
338 }
339
340 steps.push(format!(
341 "sudo -u {} {} token <account> --scopes admin",
342 options.user,
343 std::env::current_exe()
344 .map(|p| p.display().to_string())
345 .unwrap_or_else(|_| format!("/usr/local/bin/{name}"))
346 ));
347 steps
348}
349
350/// Write the plan to disk.
351pub fn apply(options: &Options) -> Result<()> {
352 let artifacts = plan(options);
353
354 println!(
355 "# {} setup — {} (instance {})",
356 brand::NAME,
357 options.shape.as_str(),
358 options.instance
359 );
360 println!();
361
362 for artifact in &artifacts {
363 if options.dry_run {
364 println!("--- {} (mode {:o})", artifact.path.display(), artifact.mode);
365 println!("{}", artifact.contents);
366 continue;
367 }
368
369 write(&artifact.path, &artifact.contents, artifact.mode)
370 .with_context(|| format!("write {}", artifact.path.display()))?;
371 println!("wrote {}", artifact.path.display());
372 }
373
374 println!();
375 println!("# then:");
376 for step in next_steps(options) {
377 println!(" {step}");
378 }
379
380 if options.shape == Shape::Isolated {
381 println!();
382 println!("# the control plane prints the public key each tenant needs:");
383 println!("# {}CONTROL_PUBLIC_KEYS=<key>", brand::env_prefix());
384 }
385 if options.dry_run {
386 println!();
387 println!("# --dry-run: nothing was written.");
388 }
389 Ok(())
390}
391
392fn write(path: &Path, contents: &str, mode: u32) -> Result<()> {
393 if let Some(dir) = path.parent() {
394 std::fs::create_dir_all(dir)?;
395 }
396
397 // Never clobber an existing file silently: a second `setup` on a live host must
398 // not discard hand-edited configuration.
399 if path.exists() {
400 let backup = path.with_extension("bak");
401 std::fs::copy(path, &backup)?;
402 println!("backed up {} -> {}", path.display(), backup.display());
403 }
404
405 std::fs::write(path, contents)?;
406
407 #[cfg(unix)]
408 {
409 use std::os::unix::fs::PermissionsExt;
410 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
411 }
412 Ok(())
413}
414
415/// Sanity-check a host before installing.
416pub fn preflight(shape: Shape) -> Vec<(String, bool, String)> {
417 let mut checks = vec![];
418
419 let git = std::process::Command::new("git").arg("--version").output();
420 checks.push((
421 "git".into(),
422 git.as_ref().map(|o| o.status.success()).unwrap_or(false),
423 git.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
424 .unwrap_or_else(|_| "not found — the wire protocol needs it".into()),
425 ));
426
427 if shape == Shape::Isolated {
428 let incus = std::process::Command::new("incus")
429 .arg("--version")
430 .output();
431 checks.push((
432 "incus".into(),
433 incus.as_ref().map(|o| o.status.success()).unwrap_or(false),
434 incus
435 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
436 .unwrap_or_else(|_| "not found — --isolated needs it".into()),
437 ));
438 }
439
440 checks.push((
441 "systemd".into(),
442 Path::new("/run/systemd/system").exists(),
443 "required for the service units".into(),
444 ));
445 checks
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn options(shape: Shape) -> Options {
453 Options {
454 shape,
455 instance: brand::NAME.to_string(),
456 domain: Some("git.example.com".into()),
457 user: brand::NAME.to_string(),
458 data_dir: PathBuf::from("/var/lib/test"),
459 bind: "127.0.0.1:8790".into(),
460 ssh_bind: "0.0.0.0:2222".into(),
461 ci_enabled: false,
462 dry_run: true,
463 }
464 }
465
466 #[test]
467 fn a_shape_must_be_chosen_explicitly() {
468 // Defaulting would pick an isolation posture on the operator's behalf.
469 assert!(Options::from_args(&[]).is_err());
470 assert!(Options::from_args(&["--standalone".into()]).is_ok());
471 assert!(Options::from_args(&["--isolated".into()]).is_ok());
472 }
473
474 #[test]
475 fn a_relative_data_dir_is_refused() {
476 let args = vec![
477 "--standalone".into(),
478 "--data-dir".into(),
479 "relative".into(),
480 ];
481 assert!(Options::from_args(&args).is_err());
482 }
483
484 #[test]
485 fn a_second_instance_shares_no_path_with_the_first() {
486 // Self-hosting runs a build instance beside the control plane. If they
487 // collided on a unit name, an env file or a data directory, installing the
488 // second would silently take over the first.
489 let instance = format!("{}-build", brand::NAME);
490 let mut build = options(Shape::Standalone);
491 build.data_dir = PathBuf::from(format!("/var/lib/{instance}"));
492 build.user = instance.clone();
493 build.instance = instance;
494
495 let first: Vec<String> = plan(&options(Shape::Isolated))
496 .iter()
497 .map(|a| a.path.display().to_string())
498 .collect();
499 let second: Vec<String> = plan(&build)
500 .iter()
501 .map(|a| a.path.display().to_string())
502 .collect();
503
504 for path in &second {
505 // The Caddyfile is genuinely shared — one proxy serves the host — so it
506 // is the single permitted overlap.
507 if path.contains("Caddyfile") {
508 continue;
509 }
510 assert!(!first.contains(path), "{path} would be written by both");
511 }
512 }
513
514 #[test]
515 fn an_instance_name_that_could_escape_its_directory_is_refused() {
516 // The name becomes /etc/<name>.env and a unit filename.
517 for bad in ["../../etc/passwd", "with space", "Upper", ""] {
518 assert!(
519 Options::from_args(&["--standalone".into(), "--instance".into(), bad.to_string()])
520 .is_err(),
521 "{bad:?} must be refused"
522 );
523 }
524 }
525
526 #[test]
527 fn the_user_and_data_directory_follow_the_instance_name() {
528 let instance = format!("{}-build", brand::NAME);
529 let o = Options::from_args(&["--standalone".into(), "--instance".into(), instance.clone()])
530 .unwrap();
531 assert_eq!(o.user, instance);
532 assert_eq!(o.data_dir, PathBuf::from(format!("/var/lib/{instance}")));
533 }
534
535 #[test]
536 fn standalone_writes_units_and_an_environment_file() {
537 let files: Vec<String> = plan(&options(Shape::Standalone))
538 .iter()
539 .map(|a| a.path.display().to_string())
540 .collect();
541
542 assert!(files.iter().any(|f| f.ends_with(".env")));
543 assert!(files
544 .iter()
545 .any(|f| f.contains("systemd") && !f.contains("jobs")));
546 assert!(files.iter().any(|f| f.contains("jobs.service")));
547 assert!(files.iter().any(|f| f.contains("Caddyfile")));
548 }
549
550 #[test]
551 fn the_environment_file_is_not_world_readable() {
552 // It is the file an operator will put a secret in.
553 let env = plan(&options(Shape::Standalone))
554 .into_iter()
555 .find(|a| a.path.display().to_string().ends_with(".env"))
556 .expect("an env file is written");
557 assert_eq!(
558 env.mode & 0o077,
559 0,
560 "the env file must not be readable by others"
561 );
562 }
563
564 #[test]
565 fn no_caddyfile_without_a_domain() {
566 // Caddy would otherwise try to obtain a certificate for nothing.
567 let mut opts = options(Shape::Standalone);
568 opts.domain = None;
569
570 let files: Vec<String> = plan(&opts)
571 .iter()
572 .map(|a| a.path.display().to_string())
573 .collect();
574 assert!(!files.iter().any(|f| f.contains("Caddyfile")));
575 }
576
577 #[test]
578 fn the_caddyfile_does_not_buffer_git_streams() {
579 let caddy = caddyfile("git.example.com", "127.0.0.1:8790", brand::NAME);
580 assert!(
581 caddy.contains("flush_interval -1"),
582 "buffering would undo the streaming the service does: {caddy}"
583 );
584 assert!(
585 caddy.contains("max_size"),
586 "a push needs a stated body ceiling"
587 );
588 }
589
590 #[test]
591 fn standalone_and_isolated_choose_different_modes() {
592 let standalone = environment(&options(Shape::Standalone));
593 assert!(standalone.contains("MODE=standalone"));
594 assert!(!standalone.contains("INCUS"));
595
596 let isolated = environment(&options(Shape::Isolated));
597 assert!(isolated.contains("MODE=control"));
598 assert!(isolated.contains("INCUS_IMAGE"));
599 }
600
601 #[test]
602 fn only_the_isolated_service_gets_the_incus_group() {
603 // The control plane drives Incus; a standalone service has no business
604 // holding a socket that can create containers.
605 assert!(service_unit(&options(Shape::Isolated), "/usr/local/bin/x")
606 .contains("SupplementaryGroups=incus-admin"));
607 assert!(
608 !service_unit(&options(Shape::Standalone), "/usr/local/bin/x").contains("incus-admin")
609 );
610 }
611
612 #[test]
613 fn the_units_confine_the_service_to_its_data_directory() {
614 let unit = service_unit(&options(Shape::Standalone), "/usr/local/bin/x");
615 for directive in [
616 "ProtectSystem=strict",
617 "ProtectHome=yes",
618 "NoNewPrivileges=yes",
619 "ReadWritePaths=/var/lib/test",
620 ] {
621 assert!(unit.contains(directive), "missing {directive}:\n{unit}");
622 }
623 }
624
625 #[test]
626 fn maintenance_is_a_separate_unit() {
627 let jobs = jobs_unit(&options(Shape::Standalone), "/usr/local/bin/x");
628 assert!(
629 jobs.contains(" jobs\n"),
630 "the jobs unit must run the jobs command"
631 );
632 assert!(!service_unit(&options(Shape::Standalone), "/usr/local/bin/x").contains(" jobs\n"));
633 }
634
635 #[test]
636 fn enabling_ci_says_what_it_costs() {
637 let mut opts = options(Shape::Standalone);
638 opts.ci_enabled = true;
639 let env = environment(&opts);
640
641 assert!(env.contains("CI_ENABLED=1"));
642 assert!(
643 env.contains("no container"),
644 "standalone CI is not a security boundary and the file should say so"
645 );
646 }
647
648 #[test]
649 fn isolated_setup_tells_the_operator_to_install_incus() {
650 let steps = next_steps(&options(Shape::Isolated)).join("\n");
651 assert!(steps.contains("incus admin init"));
652 assert!(steps.contains("usermod -aG incus-admin"));
653
654 let standalone = next_steps(&options(Shape::Standalone)).join("\n");
655 assert!(!standalone.contains("incus"));
656 }
657
658 #[test]
659 fn nothing_in_the_plan_hardcodes_the_product_name() {
660 // A rename must carry the setup with it.
661 for artifact in plan(&options(Shape::Standalone)) {
662 let path = artifact.path.display().to_string();
663 if path.contains("Caddyfile") {
664 continue;
665 }
666 assert!(
667 path.contains(brand::NAME),
668 "{path} does not track the product name"
669 );
670 }
671 }
672}