| 1 | //! `runc`, as the job's Docker Engine finds it. The Engine is started with |
| 2 | //! a folder of g1t's first on its `PATH`, holding this program under the |
| 3 | //! name `runc`, so every container it and its builder start passes |
| 4 | //! through here on its way to the real runc. Two changes, then the real |
| 5 | //! runc runs with the same arguments: |
| 6 | //! |
| 7 | //! - **BuildKit's `RUN` steps** that would join a bridge network join the |
| 8 | //! job's own network instead (their network namespace and libnetwork's |
| 9 | //! hook are taken out of the container's config), because a sandbox has |
| 10 | //! no route out of a bridge. `RUN --network=none` stays without one. |
| 11 | //! - **In a guarded job**, every container (`docker run`, services, build |
| 12 | //! steps, `docker exec`) gets the certificate the job's HTTPS is |
| 13 | //! re-signed with: the folder of certificates at `/dev/g1t-egress`, and |
| 14 | //! the variables that point common tools at it, unless the container |
| 15 | //! sets them itself. `/dev` is a filesystem of the container's own, so |
| 16 | //! nothing of this is ever written into an image's layers. |
| 17 | //! |
| 18 | //! Anything unexpected leaves the config as it was: this never stops a |
| 19 | //! container from starting. |
| 20 | |
| 21 | use serde_json::{Value, json}; |
| 22 | |
| 23 | /// Where the certificates are seen inside a container. |
| 24 | pub(crate) const CA_MOUNT: &str = "/dev/g1t-egress"; |
| 25 | /// The system's bundle, with the egress certificate added. |
| 26 | pub(crate) const BUNDLE: &str = "ca-certificates.crt"; |
| 27 | /// The egress certificate alone. |
| 28 | pub(crate) const EGRESS: &str = "egress-ca.crt"; |
| 29 | |
| 30 | /// The variables a container is given in a guarded job, as the sandbox's |
| 31 | /// own (services/runner egress.ts `EGRESS_ENV`) point its tools. |
| 32 | pub(crate) fn ca_variables() -> Vec<(&'static str, String)> { |
| 33 | let bundle = format!("{CA_MOUNT}/{BUNDLE}"); |
| 34 | vec![ |
| 35 | ("SSL_CERT_FILE", bundle.clone()), |
| 36 | ("NODE_EXTRA_CA_CERTS", format!("{CA_MOUNT}/{EGRESS}")), |
| 37 | ("REQUESTS_CA_BUNDLE", bundle.clone()), |
| 38 | ("CURL_CA_BUNDLE", bundle.clone()), |
| 39 | ("PIP_CERT", bundle.clone()), |
| 40 | ("GIT_SSL_CAINFO", bundle.clone()), |
| 41 | ("CARGO_HTTP_CAINFO", bundle), |
| 42 | ] |
| 43 | } |
| 44 | |
| 45 | /// What runc was asked to do, from its arguments. |
| 46 | #[derive(Debug, Default, PartialEq)] |
| 47 | pub(crate) struct Call { |
| 48 | pub(crate) command: Option<String>, |
| 49 | pub(crate) bundle: Option<String>, |
| 50 | /// `runc exec --process <file>`. |
| 51 | pub(crate) process: Option<String>, |
| 52 | } |
| 53 | |
| 54 | /// runc's global flags that take a value. |
| 55 | const GLOBAL_VALUES: &[&str] = &["--root", "--log", "--log-format", "--criu", "--rootless"]; |
| 56 | |
| 57 | pub(crate) fn parse(args: &[String]) -> Call { |
| 58 | let mut call = Call::default(); |
| 59 | let mut iter = args.iter().peekable(); |
| 60 | while let Some(arg) = iter.next() { |
| 61 | if call.command.is_none() { |
| 62 | if GLOBAL_VALUES.contains(&arg.as_str()) { |
| 63 | iter.next(); |
| 64 | } else if !arg.starts_with('-') { |
| 65 | call.command = Some(arg.clone()); |
| 66 | } |
| 67 | continue; |
| 68 | } |
| 69 | let (flag, inline) = match arg.split_once('=') { |
| 70 | Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())), |
| 71 | _ => (arg.as_str(), None), |
| 72 | }; |
| 73 | let mut value = || inline.clone().or_else(|| iter.next().cloned()); |
| 74 | match flag { |
| 75 | "--bundle" | "-b" => call.bundle = value(), |
| 76 | "--process" | "-p" => call.process = value(), |
| 77 | _ => {} |
| 78 | } |
| 79 | } |
| 80 | call |
| 81 | } |
| 82 | |
| 83 | /// Whether a hook entry is libnetwork's, which joins a container to a |
| 84 | /// network the Engine set up. |
| 85 | fn libnetwork_hook(hook: &Value) -> bool { |
| 86 | hook.get("args") |
| 87 | .and_then(Value::as_array) |
| 88 | .is_some_and(|args| args.iter().any(|a| a.as_str().is_some_and(|a| a.contains("libnetwork-setkey")))) |
| 89 | } |
| 90 | |
| 91 | /// Adds the certificates and their variables to a process's environment. |
| 92 | fn add_variables(process: &mut Value) -> bool { |
| 93 | let Some(env) = process.get_mut("env").and_then(Value::as_array_mut) else { return false }; |
| 94 | let mut changed = false; |
| 95 | for (name, value) in ca_variables() { |
| 96 | let prefix = format!("{name}="); |
| 97 | if !env.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with(&prefix))) { |
| 98 | env.push(json!(format!("{name}={value}"))); |
| 99 | changed = true; |
| 100 | } |
| 101 | } |
| 102 | changed |
| 103 | } |
| 104 | |
| 105 | /// Changes a container's `config.json`. `ca_dir`: the folder of |
| 106 | /// certificates to give it, in a guarded job. Returns whether it changed. |
| 107 | pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool { |
| 108 | let mut changed = false; |
| 109 | // A BuildKit step bound for a bridge network: the job's network instead. |
| 110 | if bundle.contains("/buildkit/") { |
| 111 | let mut bridged = false; |
| 112 | if let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) { |
| 113 | for list in hooks.values_mut() { |
| 114 | if let Some(entries) = list.as_array_mut() { |
| 115 | let before = entries.len(); |
| 116 | entries.retain(|hook| !libnetwork_hook(hook)); |
| 117 | bridged |= entries.len() != before; |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | if bridged && let Some(namespaces) = config.pointer_mut("/linux/namespaces").and_then(Value::as_array_mut) { |
| 122 | namespaces.retain(|ns| ns.get("type").and_then(Value::as_str) != Some("network")); |
| 123 | changed = true; |
| 124 | } |
| 125 | } |
| 126 | if let Some(dir) = ca_dir { |
| 127 | if let Some(mounts) = config.get_mut("mounts").and_then(Value::as_array_mut) |
| 128 | && !mounts.iter().any(|m| m.get("destination").and_then(Value::as_str) == Some(CA_MOUNT)) |
| 129 | { |
| 130 | mounts.push(json!({ |
| 131 | "destination": CA_MOUNT, |
| 132 | "type": "bind", |
| 133 | "source": dir, |
| 134 | "options": ["rbind", "ro", "nosuid", "nodev", "noexec"], |
| 135 | })); |
| 136 | changed = true; |
| 137 | } |
| 138 | if let Some(process) = config.get_mut("process") { |
| 139 | changed |= add_variables(process); |
| 140 | } |
| 141 | } |
| 142 | changed |
| 143 | } |
| 144 | |
| 145 | /// Changes the process of a `runc exec` (`docker exec`), which has an |
| 146 | /// environment of its own. |
| 147 | pub(crate) fn patch_process(process: &mut Value, ca_dir: Option<&str>) -> bool { |
| 148 | ca_dir.is_some() && add_variables(process) |
| 149 | } |
| 150 | |
| 151 | /// Whether this program was started as `runc`. |
| 152 | pub(crate) fn invoked_as_runc() -> bool { |
| 153 | std::env::args_os() |
| 154 | .next() |
| 155 | .and_then(|arg| std::path::Path::new(&arg).file_name().map(|name| name == "runc")) |
| 156 | .unwrap_or(false) |
| 157 | } |
| 158 | |
| 159 | /// Rewrites a JSON file in place, if `change` changes it. |
| 160 | #[cfg(unix)] |
| 161 | fn rewrite(path: &std::path::Path, change: impl FnOnce(&mut Value) -> bool) { |
| 162 | let Ok(text) = std::fs::read(path) else { return }; |
| 163 | let Ok(mut value) = serde_json::from_slice::<Value>(&text) else { return }; |
| 164 | if change(&mut value) |
| 165 | && let Ok(out) = serde_json::to_vec(&value) |
| 166 | { |
| 167 | let staged = path.with_extension("g1t"); |
| 168 | if std::fs::write(&staged, out).is_ok() { |
| 169 | let _ = std::fs::rename(&staged, path); |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /// `runc …`: changes the container's config, then becomes the real runc. |
| 175 | #[cfg(unix)] |
| 176 | pub(crate) fn main() -> ! { |
| 177 | use std::os::unix::process::CommandExt; |
| 178 | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 179 | let call = parse(&args); |
| 180 | let ca_dir = std::env::var("G1T_DOCKER_CA_DIR").ok().filter(|dir| std::path::Path::new(dir).is_dir()); |
| 181 | match (call.command.as_deref(), &call.bundle, &call.process) { |
| 182 | (Some("create" | "run"), Some(bundle), _) => { |
| 183 | rewrite(&std::path::Path::new(bundle).join("config.json"), |config| patch_config(config, bundle, ca_dir.as_deref())); |
| 184 | } |
| 185 | (Some("exec"), _, Some(process)) => rewrite(std::path::Path::new(process), |process| patch_process(process, ca_dir.as_deref())), |
| 186 | _ => {} |
| 187 | } |
| 188 | let real = std::env::var("G1T_REAL_RUNC").unwrap_or_else(|_| "/usr/bin/runc".into()); |
| 189 | let error = std::process::Command::new(&real).arg0("runc").args(&args).exec(); |
| 190 | eprintln!("g1t-runner: could not run {real}: {error}"); |
| 191 | std::process::exit(127) |
| 192 | } |
| 193 | |
| 194 | #[cfg(not(unix))] |
| 195 | pub(crate) fn main() -> ! { |
| 196 | eprintln!("g1t-runner: runc runs on Linux only"); |
| 197 | std::process::exit(127) |
| 198 | } |
| 199 | |
| 200 | #[cfg(test)] |
| 201 | mod tests { |
| 202 | use super::*; |
| 203 | |
| 204 | fn args(text: &str) -> Vec<String> { |
| 205 | text.split_whitespace().map(str::to_owned).collect() |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn calls_are_read_as_containerd_and_buildkit_make_them() { |
| 210 | let call = parse(&args( |
| 211 | "--root /var/run/docker/runtime-runc/moby --log /x/log.json --log-format json create --bundle /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc --pid-file /x/init.pid abc", |
| 212 | )); |
| 213 | assert_eq!(call.command.as_deref(), Some("create")); |
| 214 | assert_eq!(call.bundle.as_deref(), Some("/var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc")); |
| 215 | let call = parse(&args("--log /var/lib/docker/buildkit/executor/runc-log.json --log-format json run --bundle /var/lib/docker/buildkit/executor/x1 --keep x1")); |
| 216 | assert_eq!((call.command.as_deref(), call.bundle.as_deref()), (Some("run"), Some("/var/lib/docker/buildkit/executor/x1"))); |
| 217 | let call = parse(&args("--root /r exec --process /tmp/runc-process1 --detach --pid-file /p abc")); |
| 218 | assert_eq!((call.command.as_deref(), call.process.as_deref()), (Some("exec"), Some("/tmp/runc-process1"))); |
| 219 | assert_eq!(parse(&args("--root=/r start abc")).command.as_deref(), Some("start")); |
| 220 | assert_eq!(parse(&args("create --bundle=/b x")).bundle.as_deref(), Some("/b")); |
| 221 | assert_eq!(parse(&args("--version")), Call::default()); |
| 222 | } |
| 223 | |
| 224 | fn build_step(hooked: bool) -> Value { |
| 225 | let hooks = if hooked { |
| 226 | json!({ "prestart": [{ "path": "/proc/21/exe", "args": ["libnetwork-setkey", "-exec-root=/var/run/docker", "abc", "def"] }] }) |
| 227 | } else { |
| 228 | json!({}) |
| 229 | }; |
| 230 | json!({ |
| 231 | "hostname": "buildkitsandbox", |
| 232 | "process": { "env": ["PATH=/usr/bin", "SSL_CERT_FILE=/mine.pem"] }, |
| 233 | "mounts": [{ "destination": "/proc" }, { "destination": "/dev", "type": "tmpfs" }], |
| 234 | "linux": { "namespaces": [{ "type": "pid" }, { "type": "network" }, { "type": "mount" }] }, |
| 235 | "hooks": hooks, |
| 236 | }) |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn build_steps_bound_for_a_bridge_join_the_jobs_network() { |
| 241 | let mut config = build_step(true); |
| 242 | assert!(patch_config(&mut config, "/var/lib/docker/buildkit/executor/x1", None)); |
| 243 | let namespaces: Vec<&str> = config["linux"]["namespaces"].as_array().unwrap().iter().map(|n| n["type"].as_str().unwrap()).collect(); |
| 244 | assert_eq!(namespaces, vec!["pid", "mount"]); |
| 245 | assert!(config["hooks"]["prestart"].as_array().unwrap().is_empty()); |
| 246 | // RUN --network=none: no libnetwork hook, so its own empty network. |
| 247 | let mut config = build_step(false); |
| 248 | assert!(!patch_config(&mut config, "/var/lib/docker/buildkit/executor/x2", None)); |
| 249 | assert_eq!(config["linux"]["namespaces"].as_array().unwrap().len(), 3); |
| 250 | // A container the Engine runs is the API proxy's business, not this. |
| 251 | let mut config = build_step(true); |
| 252 | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", None)); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn guarded_containers_get_the_certificates_without_losing_their_own_settings() { |
| 257 | let mut config = build_step(false); |
| 258 | assert!(patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); |
| 259 | let mounts = config["mounts"].as_array().unwrap(); |
| 260 | let last = mounts.last().unwrap(); |
| 261 | assert_eq!(last["destination"], CA_MOUNT); |
| 262 | assert_eq!(last["source"], "/run/g1t-docker/certs"); |
| 263 | assert!(last["options"].as_array().unwrap().contains(&json!("ro"))); |
| 264 | let env: Vec<&str> = config["process"]["env"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect(); |
| 265 | assert!(env.contains(&"SSL_CERT_FILE=/mine.pem")); |
| 266 | assert!(!env.contains(&"SSL_CERT_FILE=/dev/g1t-egress/ca-certificates.crt")); |
| 267 | assert!(env.contains(&"NODE_EXTRA_CA_CERTS=/dev/g1t-egress/egress-ca.crt")); |
| 268 | // Patching twice changes nothing more. |
| 269 | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); |
| 270 | let mut process = json!({ "env": ["PATH=/bin"] }); |
| 271 | assert!(patch_process(&mut process, Some("/run/g1t-docker/certs"))); |
| 272 | assert!(!patch_process(&mut json!({ "env": [] }), None)); |
| 273 | } |
| 274 | } |