A self-hosted runner leaves the computer it runs on as little of a job's secrets as it can. A workflow job's token, which sits where the computer can see it (a Windows job's script in the folder Windows Sandbox reads, a container's environment), is spent on fetching the job: the harness asks for it (rotate, set by the self-hosted runner through G1T_ROTATE_JOB_TOKEN), and job_spec answers with a new token beside the spec, swapped in last and only while the old one is still the job's, so a copy left behind, or a second fetch racing the first, is refused and can't fetch the spec, its secrets or a G1T_TOKEN again. The harness reports, logs, uploads and caches with the new one, masked in the log, and the toolkit's runtime token is signed for it. Harnesses that don't ask, g1t's own sandboxes among them (which keep their token to report a sandbox that stopped), keep theirs, and a harness that asks a g1t that doesn't rotate gets no new token and carries on as before. A Windows job's folder is deleted when the job ends, as before, and now also when the runner next starts after being stopped mid-job (a crash, or taskkill from sandbox down).
The runner's folders are their owner's alone. On macOS and Linux they are made 0700, as their files were already 0600. On Windows a folder outside the user's profile (--dir, G1T_RUNNER_DIR or --state-dir at C:\runner) inherited read access for every user, credential, job scripts and VM disk included: the runner now reads the folder's DACL as SDDL, and when Everyone, Users, Authenticated Users or the like may read it, leaves it to the account, SYSTEM and Administrators with nothing inherited (icacls, by SID), and refuses to register, run or start the sandbox with a clear fix if it stays open. private.rs holds this and write_private, which the launcher and config::save now share. An update's carried registration is removed as soon as no sandbox needs it, and whenever the old system is still there; the next up imports one an update left half-way whether or not it makes the VM. g1t-runner sandbox status and up warn of a runner registered for host mode under the same account (~/.g1t-runner, G1T_RUNNER_DIR, or the desktop app's runner/host folder), whose jobs could look inside the sandbox, and of a state folder others can read: a Warning: line, and warnings in status --json (added; the schema is unchanged). The WSL distribution's default user is a locked account with no shell or home, made in the image, so Explorer at \\wsl.localhost\g1t-sandbox or a bare wsl -d is no longer root; every call the launcher makes names root itself. On macOS and Linux VMs the launcher takes the registration token out of the seed disk once the runner has registered, as the WSL guest and Sysbox already did. The self-hosted runners guide has a section, What your computer can see: the sandbox keeps jobs from the computer, a job's log and secrets go from inside it straight to g1t, anything running as the same account (or an administrator, root in another WSL distribution, the docker group under Sysbox) can look inside a running sandbox, so no host-mode runner or untrusted software under that account; the job's token on the computer is spent after first use; the folders are owner-only. The desktop guide points to it under How jobs run, and the status table lists warnings.
| 769 | 769 | _ => (rest.to_owned(), "job_report"), | |
| 770 | 770 | }; | |
| 771 | 771 | let body = json_body(&mut request).await; | |
| 772 | + | // `rotate`: a harness that takes a new token with the spec and | |
| 773 | + | // lets go of the one it asked with (plan.rs `job_spec`). | |
| 774 | + | let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true); | |
| 772 | 775 | let answered: Outcome<Value> = g1t_kit::call( | |
| 773 | 776 | &services.actions, | |
| 774 | 777 | method, | |
| 775 | − | &json!({ "job": job, "token": body["token"], "report": body["report"] }), | |
| 778 | + | &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }), | |
| 776 | 779 | ) | |
| 777 | 780 | .await?; | |
| 778 | 781 | return match answered { | |
| ⋯ | |||
| 942 | 945 | #[test] | |
| 943 | 946 | fn a_job_spec_gets_the_toolkits_variables() { | |
| 944 | 947 | // As the actions service sends it, converted as the API does. | |
| 945 | − | let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } }); | |
| 948 | + | let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true }, "nextToken": "n" }); | |
| 946 | 949 | let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN); | |
| 947 | 950 | super::with_runtime(&mut spec, "https://api.g1t.sh", true); | |
| 948 | 951 | assert!(spec.get("runtime").is_none(), "the runner never sees it"); | |
| 952 | + | // The token that replaces the one the harness asked with. | |
| 953 | + | assert_eq!(spec["next_token"], "n"); | |
| 949 | 954 | let vars = &spec["variables"]; | |
| 950 | 955 | assert_eq!(vars["GITHUB_SHA"], "abc"); | |
| 951 | 956 | assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s"); | |
| 213 | 213 | The sandbox also needs virtualization turned on in your computer's | |
| 214 | 214 | firmware (Intel VT-x or AMD-V), which most computers have on. | |
| 215 | 215 | ||
| 216 | + | The sandbox keeps jobs from your computer, not your computer from the | |
| 217 | + | sandbox: anything running as your account can look inside it while a job | |
| 218 | + | runs. So don't choose **Directly on this computer** under the same | |
| 219 | + | account you use for the sandbox, and don't run software you don't trust | |
| 220 | + | there. A runner registered for running jobs directly stays registered when | |
| 221 | + | you choose another way, so remove it once you no longer use it: | |
| 222 | + | `g1t-runner remove --dir <data folder>/host`, with the data folder below. | |
| 223 | + | `g1t-runner sandbox status` warns while one is there. A job's secrets go from inside the sandbox | |
| 224 | + | straight to g1t, the job's token left on your computer is spent once the | |
| 225 | + | job has started, and the runner's folders are yours alone. [What your | |
| 226 | + | computer can see](/guides/self-hosted-runners/#what-your-computer-can-see) | |
| 227 | + | has the details. | |
| 228 | + | ||
| 216 | 229 | For the sandbox, the app runs the same commands you would, with its own | |
| 217 | 230 | copy of `g1t-runner` and its state in the app's local data folder, which | |
| 218 | 231 | never roams to other computers with your profile |
| 313 | 313 | run on a Mac or a Linux machine without Sysbox. Windows is where the | |
| 314 | 314 | sandbox has been checked end to end. | |
| 315 | 315 | ||
| 316 | + | ### What your computer can see | |
| 317 | + | ||
| 318 | + | The sandbox keeps jobs from reaching your computer. It does not keep your | |
| 319 | + | computer from looking at the sandbox, and so at what a job has while it | |
| 320 | + | runs: | |
| 321 | + | ||
| 322 | + | - **A job's log and secrets go from inside the sandbox straight to g1t.** | |
| 323 | + | The runner inside fetches the job, its secrets and its `G1T_TOKEN` | |
| 324 | + | itself, and sends its log back the same way, masked. None of it passes | |
| 325 | + | through a log or file of your computer's. | |
| 326 | + | - **Anything running as your account can look inside a running | |
| 327 | + | sandbox**, as you can: open the WSL distribution with `wsl -u root`, | |
| 328 | + | run a command in a Windows Sandbox, or read the VM's disk. So can an | |
| 329 | + | administrator, root in another WSL distribution (all of WSL 2 shares one | |
| 330 | + | VM), and on Linux with Sysbox anyone in the `docker` group. Don't run a | |
| 331 | + | [host-mode](#host-mode-is-deprecated) runner, or software you don't | |
| 332 | + | trust, under the account the sandbox runs as: what its jobs run could | |
| 333 | + | read the sandbox's runner credentials and the secrets of the job | |
| 334 | + | running in it. `g1t-runner sandbox status` warns when it finds a | |
| 335 | + | host-mode runner registered for the same account (in `~/.g1t-runner`, | |
| 336 | + | `G1T_RUNNER_DIR`, or the desktop app's folder for running jobs directly | |
| 337 | + | on the computer). Remove it with `g1t-runner remove --dir <its folder>`, | |
| 338 | + | or register it under another account. | |
| 339 | + | - **The job's token on your computer is spent after its first use.** A | |
| 340 | + | workflow job reaches its runner with a token of its own, which sits | |
| 341 | + | where your computer can see it: in a Windows job's script, in the job | |
| 342 | + | folder Windows Sandbox reads, and in a container's environment. The | |
| 343 | + | first thing the runner in the sandbox does is fetch the job with it, | |
| 344 | + | and g1t answers with a new token that only the sandbox has; the first | |
| 345 | + | stops working at once, for that and for everything else. A copy left | |
| 346 | + | behind can't fetch the job, its secrets or a `G1T_TOKEN` again. A job's | |
| 347 | + | folder is deleted when the job ends, and a runner that was stopped | |
| 348 | + | mid-job (a crash, or `taskkill`) deletes what it left when it next | |
| 349 | + | starts. | |
| 350 | + | - **The registration token goes once the runner has registered.** It is | |
| 351 | + | kept only until the runner inside has registered with it; then the | |
| 352 | + | launcher takes it out of the VM's seed disk on macOS and Linux, and the | |
| 353 | + | sandbox out of its own settings. On Windows, an `update` keeps the | |
| 354 | + | runner's registration on your computer only while the VM is swapped. | |
| 355 | + | - **The state folders are yours alone.** The runner's folder | |
| 356 | + | (`~/.g1t-runner`, `--dir` or `G1T_RUNNER_DIR`) and the sandbox's | |
| 357 | + | (`--state-dir`) hold the runners' credentials, a Windows job's script | |
| 358 | + | while it runs, and the VM's disk. On macOS and Linux they are made | |
| 359 | + | `0700` and their files `0600`. On Windows a folder in your profile is | |
| 360 | + | already yours alone; one elsewhere, such as `C:\runner`, would let every | |
| 361 | + | user of the computer read it, so the runner makes it private (you, | |
| 362 | + | SYSTEM and Administrators, and nothing inherited from the folder | |
| 363 | + | above). If it can't, it refuses to start and says how to fix it. | |
| 364 | + | - **On Windows, opening the sandbox's distribution without naming a user** | |
| 365 | + | (Explorer at `\\wsl.localhost\g1t-sandbox`, or `wsl -d g1t-sandbox`) | |
| 366 | + | gets a locked account with no shell, which can't read the runner's | |
| 367 | + | registration. The launcher names root itself for what it does there. | |
| 368 | + | ||
| 316 | 369 | ### The status as JSON | |
| 317 | 370 | ||
| 318 | 371 | `g1t-runner sandbox status --json` prints one JSON object, which [the | |
| ⋯ | |||
| 341 | 394 | "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 342 | 395 | "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." } | |
| 343 | 396 | ], | |
| 344 | − | "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox" | |
| 397 | + | "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox", | |
| 398 | + | "warnings": [] | |
| 345 | 399 | } | |
| 346 | 400 | ``` | |
| 347 | 401 | ||
| ⋯ | |||
| 354 | 408 | | `error` | A sentence for a person when something is wrong, or null. | | |
| 355 | 409 | | `capabilities` | Each kind of job the computer can run sandboxed: `linux`, and `windows` or `macos`. `available` says whether it can be had here; when not, `cause` says why and `fix` what to do. Each has its own `state`, `job` and `runner_version`. A native one whose runner didn't start is in `error`, with why in `cause`. | | |
| 356 | 410 | | `turned_off` | On each capability: someone stopped it (`down --only`, `--no-native`). Its `state` is then `stopped`: stopped by a person, not failing, and nothing in `error`. | | |
| 411 | + | | `warnings` | Sentences about what on the computer weakens the sandbox ([what your computer can see](#what-your-computer-can-see)): a host-mode runner registered for the same account, or a state folder other users can read. Empty when nothing does; left out by earlier versions of `g1t-runner`. `status` without `--json` prints each as a line starting `Warning:`. | | |
| 357 | 412 | ||
| 358 | 413 | Fields are only added; `schema` changes if one changes meaning. | |
| 359 | 414 | ||
| ⋯ | |||
| 661 | 716 | given and remove itself, and nothing else; every other endpoint refuses | |
| 662 | 717 | it. It is kept only as a hash on g1t's side, rotates every day, and stops | |
| 663 | 718 | working the moment the runner is removed. It is never given to a job: | |
| 664 | − | each job gets only its own short-lived token. | |
| 719 | + | each job gets only its own short-lived token, which the job spends on | |
| 720 | + | fetching itself and replaces with one only the job has. The runner's | |
| 721 | + | folder is its owner's alone, and the runner refuses to start in one | |
| 722 | + | other users can read ([what your computer can see](#what-your-computer-can-see)). | |
| 665 | 723 | - **Isolation**: run the runner's image under Sysbox. Each job then gets | |
| 666 | 724 | a container of its own inside the runner's container, removed after, | |
| 667 | 725 | and neither reaches the machine. A job with `--no-docker` runs as the | |
| 991 | 991 | pub token: String, | |
| 992 | 992 | #[serde(default)] | |
| 993 | 993 | pub report: Value, | |
| 994 | + | /// `job_spec` only: the harness takes a new token with the spec, and | |
| 995 | + | /// the one it asked with stops working, so a copy of it left on the | |
| 996 | + | /// runner's computer cannot fetch the job's secrets again. Harnesses | |
| 997 | + | /// that predate it never ask, and keep their token. | |
| 998 | + | #[serde(default)] | |
| 999 | + | pub rotate: bool, | |
| 994 | 1000 | } | |
| 995 | 1001 | ||
| 996 | 1002 | /// What the runner needs to start a job's sandbox. |
| 35 | 35 | use report::{Api, Log}; | |
| 36 | 36 | ||
| 37 | 37 | const TEMP: &str = "/home/runner/_temp"; | |
| 38 | + | /// Set to `1` by the self-hosted runner: the job's token is spent on its | |
| 39 | + | /// spec, and the harness carries on with the one that replaces it | |
| 40 | + | /// (`report::Api::spec`). A `G1T_` variable, so no step sees it. | |
| 41 | + | pub(crate) const ROTATE_TOKEN: &str = "G1T_ROTATE_JOB_TOKEN"; | |
| 38 | 42 | ||
| 39 | 43 | /// Who is running steps: the job itself, or a composite action inside it. | |
| 40 | 44 | #[derive(Clone, Default)] | |
| ⋯ | |||
| 1209 | 1213 | } | |
| 1210 | 1214 | ||
| 1211 | 1215 | pub(crate) fn main() -> i32 { | |
| 1212 | − | let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) { | |
| 1216 | + | let mut api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) { | |
| 1213 | 1217 | (Ok(base), Ok(job), Ok(token)) => Api { base, job, token }, | |
| 1214 | 1218 | _ => { | |
| 1215 | 1219 | eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed"); | |
| 1216 | 1220 | return 2; | |
| 1217 | 1221 | } | |
| 1218 | 1222 | }; | |
| 1219 | − | let spec = match api.spec() { | |
| 1223 | + | // A self-hosted runner asks for the token to be spent on the spec | |
| 1224 | + | // (selfhosted/exec.rs): there, the token it was given sits where the | |
| 1225 | + | // computer can see it (a Windows job's script, a container's | |
| 1226 | + | // environment). g1t's own sandboxes keep theirs to report a sandbox | |
| 1227 | + | // that stopped, and do not ask. | |
| 1228 | + | let rotate = std::env::var(ROTATE_TOKEN).is_ok_and(|v| v == "1"); | |
| 1229 | + | let spec = match api.spec(rotate) { | |
| 1220 | 1230 | Ok(spec) => spec, | |
| 1221 | 1231 | Err(error) => { | |
| 1222 | 1232 | eprintln!("g1t-runner: could not fetch the job: {error:#}"); | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | impl Api { | |
| 82 | − | pub(crate) fn spec(&self) -> Result<Value> { | |
| 83 | − | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)) | |
| 84 | − | .timeout(Duration::from_secs(60)) | |
| 85 | − | .send_json(json!({ "token": self.token }))?; | |
| 86 | − | Ok(response.into_json()?) | |
| 82 | + | /// The job's spec. With `rotate`, g1t spends the token it was asked | |
| 83 | + | /// with and sends a new one beside the spec, which this takes for | |
| 84 | + | /// everything after (`next_token`): a copy of the first left on the | |
| 85 | + | /// runner's computer cannot fetch the job, its secrets or a G1T_TOKEN | |
| 86 | + | /// again. A g1t that predates it sends none, and the token stays. | |
| 87 | + | pub(crate) fn spec(&mut self, rotate: bool) -> Result<Value> { | |
| 88 | + | let mut body = json!({ "token": self.token }); | |
| 89 | + | if rotate { | |
| 90 | + | body["rotate"] = json!(true); | |
| 91 | + | } | |
| 92 | + | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?; | |
| 93 | + | let mut spec: Value = response.into_json()?; | |
| 94 | + | self.take_next_token(&mut spec); | |
| 95 | + | Ok(spec) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Takes the token a spec carries in place of the one it was fetched | |
| 99 | + | /// with, and masks it in the log. | |
| 100 | + | fn take_next_token(&mut self, spec: &mut Value) { | |
| 101 | + | let next = spec.as_object_mut().and_then(|s| s.remove("next_token").or_else(|| s.remove("nextToken"))); | |
| 102 | + | if let Some(next) = next.as_ref().and_then(Value::as_str).filter(|t| !t.is_empty()) { | |
| 103 | + | self.token = next.to_owned(); | |
| 104 | + | if let Some(masks) = spec.get_mut("masks").and_then(Value::as_array_mut) { | |
| 105 | + | masks.push(json!(next)); | |
| 106 | + | } | |
| 107 | + | } | |
| 87 | 108 | } | |
| 88 | 109 | ||
| 89 | 110 | /// Where to fetch another repository's action from: `{"source": "g1t", | |
| ⋯ | |||
| 442 | 463 | use super::*; | |
| 443 | 464 | ||
| 444 | 465 | #[test] | |
| 466 | + | fn the_token_a_spec_carries_replaces_the_one_it_was_fetched_with() { | |
| 467 | + | let mut api = Api::offline(); | |
| 468 | + | let mut spec = json!({ "job": "job_1", "masks": ["s3cret"], "next_token": "fresh" }); | |
| 469 | + | api.take_next_token(&mut spec); | |
| 470 | + | assert_eq!(api.token, "fresh"); | |
| 471 | + | assert!(spec.get("next_token").is_none(), "the job's steps never see it"); | |
| 472 | + | assert_eq!(spec["masks"], json!(["s3cret", "fresh"])); | |
| 473 | + | // A g1t that does not rotate sends none: the token stays. | |
| 474 | + | let mut spec = json!({ "job": "job_1", "masks": [] }); | |
| 475 | + | api.take_next_token(&mut spec); | |
| 476 | + | assert_eq!(api.token, "fresh"); | |
| 477 | + | assert_eq!(spec["masks"], json!([])); | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | #[test] | |
| 445 | 481 | fn every_line_is_stamped_with_the_time_as_github_stamps_it() { | |
| 446 | 482 | assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00.0000000Z"); | |
| 447 | 483 | assert_eq!(stamp(UNIX_EPOCH + Duration::new(1_791_797_067, 123_456_789)), "2026-10-12T09:24:27.1234567Z"); | |
| 99 | 99 | serde_json::from_str(&text).with_context(|| format!("{} does not read", file.display())) | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | − | /// Writes the configuration so only its owner can read it, through a | |
| 103 | − | /// temporary file, so a crash never leaves half a credential. | |
| 102 | + | /// Writes the configuration so only its owner can read it, in a folder | |
| 103 | + | /// only its owner can read (private.rs), through a temporary file, so a | |
| 104 | + | /// crash never leaves half a credential. | |
| 104 | 105 | pub fn save(folder: &Path, config: &Config) -> Result<()> { | |
| 105 | − | std::fs::create_dir_all(folder).with_context(|| format!("could not make {}", folder.display()))?; | |
| 106 | − | let file = path(folder); | |
| 107 | − | let temp = folder.join("config.json.new"); | |
| 108 | − | std::fs::write(&temp, serde_json::to_string_pretty(config)?)?; | |
| 109 | − | #[cfg(unix)] | |
| 110 | − | { | |
| 111 | − | use std::os::unix::fs::PermissionsExt; | |
| 112 | − | std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?; | |
| 113 | − | } | |
| 114 | − | std::fs::rename(&temp, &file).with_context(|| format!("could not write {}", file.display()))?; | |
| 115 | − | Ok(()) | |
| 106 | + | super::private::folder(folder, "--dir")?; | |
| 107 | + | super::private::write_private(&path(folder), serde_json::to_string_pretty(config)?.as_bytes()) | |
| 116 | 108 | } | |
| 117 | 109 | ||
| 118 | 110 | pub fn forget(folder: &Path) { |
| 203 | 203 | env.insert("G1T_API".into(), config.api.clone()); | |
| 204 | 204 | env.insert("ACTIONS_JOB".into(), work.id.clone()); | |
| 205 | 205 | env.insert("ACTIONS_TOKEN".into(), token.clone()); | |
| 206 | + | // That token is where this computer can see it (a Windows job's | |
| 207 | + | // script in the job's folder, a container's environment): the | |
| 208 | + | // harness spends it on fetching the job and works on with a new | |
| 209 | + | // one only it has. | |
| 210 | + | env.insert(crate::actions::ROTATE_TOKEN.into(), "1".into()); | |
| 206 | 211 | } | |
| 207 | 212 | "agent" => { | |
| 208 | 213 | for (name, value) in work.env.clone().unwrap_or_default() { | |
| ⋯ | |||
| 622 | 627 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); | |
| 623 | 628 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 624 | 629 | assert!(env.values().all(|value| !value.contains("g1tr_"))); | |
| 630 | + | // That token is spent on fetching the job. | |
| 631 | + | assert_eq!(env[crate::actions::ROTATE_TOKEN], "1"); | |
| 632 | + | assert!(!harness_env(&config(), &assignment("agent")).unwrap().contains_key(crate::actions::ROTATE_TOKEN)); | |
| 625 | 633 | } | |
| 626 | 634 | ||
| 627 | 635 | #[test] | |
| 852 | 852 | #[test] | |
| 853 | 853 | fn the_image_turns_off_every_way_to_windows() { | |
| 854 | 854 | let conf = WSL_CONF.replace(' ', ""); | |
| 855 | − | for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "default=root"] { | |
| 855 | + | for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "[user]\ndefault=sandbox"] { | |
| 856 | 856 | assert!(conf.contains(setting), "{setting}"); | |
| 857 | 857 | } | |
| 858 | + | // What opens the distribution without naming a user is not root; | |
| 859 | + | // the launcher names root on every call (wsl.rs). | |
| 860 | + | assert!(!conf.contains("default=root")); | |
| 861 | + | let dockerfile = include_str!("../../../../../deploy/sandbox/Dockerfile"); | |
| 862 | + | assert!(dockerfile.contains("useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox")); | |
| 863 | + | assert!(dockerfile.contains("passwd --lock sandbox")); | |
| 858 | 864 | } | |
| 859 | 865 | ||
| 860 | 866 | #[test] |
| 68 | 68 | //! "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 69 | 69 | //! "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, …" } | |
| 70 | 70 | //! ], | |
| 71 | − | //! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox" | |
| 71 | + | //! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox", | |
| 72 | + | //! "warnings": [] | |
| 72 | 73 | //! } | |
| 73 | 74 | //! ``` | |
| 74 | 75 | //! | |
| ⋯ | |||
| 77 | 78 | //! or `error`; `error` is a sentence for a person, set whenever something | |
| 78 | 79 | //! is wrong, and `cause`/`fix` say why a capability is unavailable, or for | |
| 79 | 80 | //! a native one in `error` why it didn't start, and what to do. | |
| 81 | + | //! `warnings` holds sentences about what on the computer weakens the | |
| 82 | + | //! sandbox: a runner of the same account registered for host mode, whose | |
| 83 | + | //! jobs could look inside it, or a state folder others can read. It is | |
| 84 | + | //! empty when nothing does, and left out by launchers before it. | |
| 80 | 85 | //! `turned_off` is true for a capability someone stopped (`down --only`, | |
| 81 | 86 | //! `--no-native`), whose state is then `stopped`: stopped by a person, not | |
| 82 | 87 | //! failing. `job` is what the runner is running now, or null. Fields | |
| ⋯ | |||
| 102 | 107 | use serde::{Deserialize, Serialize}; | |
| 103 | 108 | ||
| 104 | 109 | use super::native::{Availability, MacVm, WsbFound}; | |
| 110 | + | use super::private::{self, write_private}; | |
| 105 | 111 | use super::{JobStatus, Options, RunnerStatus, VERSION, config, log}; | |
| 106 | 112 | use guest::{GuestStatus, Settings}; | |
| 107 | 113 | ||
| ⋯ | |||
| 248 | 254 | pub resources: Resources, | |
| 249 | 255 | pub capabilities: Vec<Capability>, | |
| 250 | 256 | pub state_dir: String, | |
| 257 | + | /// Sentences about what on the computer weakens the sandbox (a | |
| 258 | + | /// host-mode runner of the same account, a state folder others can | |
| 259 | + | /// read); empty when nothing does. Older launchers leave it out. | |
| 260 | + | #[serde(default)] | |
| 261 | + | pub warnings: Vec<String>, | |
| 251 | 262 | } | |
| 252 | 263 | ||
| 253 | 264 | /// One kind of job this computer can run sandboxed. | |
| ⋯ | |||
| 267 | 278 | pub turned_off: bool, | |
| 268 | 279 | } | |
| 269 | 280 | ||
| 270 | − | pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { | |
| 271 | − | let temp = path.with_extension("new"); | |
| 272 | − | std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?; | |
| 273 | − | #[cfg(unix)] | |
| 274 | − | { | |
| 275 | − | use std::os::unix::fs::PermissionsExt; | |
| 276 | − | std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?; | |
| 277 | − | } | |
| 278 | − | std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?; | |
| 279 | − | Ok(()) | |
| 280 | − | } | |
| 281 | − | ||
| 282 | 281 | // --- The computer ------------------------------------------------------------- | |
| 283 | 282 | ||
| 284 | 283 | /// Whether a process is still running. | |
| ⋯ | |||
| 655 | 654 | resources: saved.resources, | |
| 656 | 655 | capabilities, | |
| 657 | 656 | state_dir: dir.display().to_string(), | |
| 657 | + | warnings: warnings(dir), | |
| 658 | + | } | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // --- What weakens it --------------------------------------------------------------- | |
| 662 | + | ||
| 663 | + | /// What on this computer lets someone look inside the sandbox: a runner of | |
| 664 | + | /// this account's that runs jobs as it, and a state folder others can read. | |
| 665 | + | fn warnings(dir: &Path) -> Vec<String> { | |
| 666 | + | let mut said: Vec<String> = host_mode_runners(&host_runner_folders()).iter().map(|folder| host_mode_warning(folder)).collect(); | |
| 667 | + | if dir.exists() { | |
| 668 | + | let readers = private::readers(dir); | |
| 669 | + | if !readers.is_empty() { | |
| 670 | + | said.push(format!( | |
| 671 | + | "{} can be read by {}: the runners' credentials and the VM's disk are in it. The next `g1t-runner sandbox up` makes it private, or refuses to start.", | |
| 672 | + | dir.display(), | |
| 673 | + | readers.join(", ") | |
| 674 | + | )); | |
| 675 | + | } | |
| 658 | 676 | } | |
| 677 | + | said | |
| 659 | 678 | } | |
| 660 | 679 | ||
| 680 | + | /// Where a runner of this account's may be registered: `g1t-runner`'s own | |
| 681 | + | /// folder (`G1T_RUNNER_DIR`, `~/.g1t-runner`), and the desktop app's for | |
| 682 | + | /// running jobs directly on the computer. | |
| 683 | + | fn host_runner_folders() -> Vec<PathBuf> { | |
| 684 | + | let mut folders = vec![config::folder(None)]; | |
| 685 | + | let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from); | |
| 686 | + | if let Some(home) = &home { | |
| 687 | + | folders.push(home.join(".g1t-runner")); | |
| 688 | + | } | |
| 689 | + | // The desktop app's data folder (its `app_data_dir`): runner/host. | |
| 690 | + | let data = match std::env::consts::OS { | |
| 691 | + | "windows" => std::env::var_os("APPDATA").map(PathBuf::from), | |
| 692 | + | "macos" => home.as_ref().map(|h| h.join("Library").join("Application Support")), | |
| 693 | + | _ => std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local").join("share"))), | |
| 694 | + | }; | |
| 695 | + | if let Some(data) = data { | |
| 696 | + | folders.push(data.join("sh.g1t.desktop").join("runner").join("host")); | |
| 697 | + | } | |
| 698 | + | folders.dedup(); | |
| 699 | + | folders | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | /// Those of `folders` that hold a runner registered for host mode: jobs as | |
| 703 | + | /// processes of this account, neither in a container nor in a VM. | |
| 704 | + | fn host_mode_runners(folders: &[PathBuf]) -> Vec<PathBuf> { | |
| 705 | + | folders.iter().filter(|folder| config::load(folder).is_ok_and(|c| !c.docker && !c.native)).cloned().collect() | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | fn host_mode_warning(folder: &Path) -> String { | |
| 709 | + | format!( | |
| 710 | + | "A runner registered in {} runs jobs directly on this computer, as you (host mode). Whenever it runs, what its jobs run can read this sandbox's runner credentials and reach inside the sandbox, as you can. Remove it (`g1t-runner remove --dir {}`), or register it under another account.", | |
| 711 | + | folder.display(), | |
| 712 | + | folder.display() | |
| 713 | + | ) | |
| 714 | + | } | |
| 715 | + | ||
| 661 | 716 | /// The native capability's state: running while its runner lives; | |
| 662 | 717 | /// stopped when turned off, or registered and not running; `error` when | |
| 663 | 718 | /// it didn't start; `absent` before it is registered. | |
| ⋯ | |||
| 701 | 756 | } | |
| 702 | 757 | } | |
| 703 | 758 | } | |
| 759 | + | for warning in &status.warnings { | |
| 760 | + | println!("Warning: {warning}"); | |
| 761 | + | } | |
| 704 | 762 | if let Some(error) = &status.error { | |
| 705 | 763 | println!("Error: {error}"); | |
| 706 | 764 | } | |
| ⋯ | |||
| 771 | 829 | }; | |
| 772 | 830 | let json = options.flag("json"); | |
| 773 | 831 | let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox")); | |
| 832 | + | // The state folder holds the runners' credentials and the VM's disk: | |
| 833 | + | // only this account's, or nothing is made or started in it. | |
| 834 | + | let private = || private::folder(&dir, "--state-dir"); | |
| 774 | 835 | let outcome = match args.first().map(String::as_str) { | |
| 775 | − | Some("up") => up(&options, &dir), | |
| 776 | − | Some("down") => down(&options, &dir), | |
| 777 | − | Some("update") => update_sandbox(&options, &dir), | |
| 836 | + | Some("up") => private().and_then(|()| up(&options, &dir)), | |
| 837 | + | Some("down") => private().and_then(|()| down(&options, &dir)), | |
| 838 | + | Some("update") => private().and_then(|()| update_sandbox(&options, &dir)), | |
| 778 | 839 | Some("status") => Ok(()), | |
| 779 | 840 | _ => { | |
| 780 | 841 | println!("{HELP}"); | |
| ⋯ | |||
| 889 | 950 | if local.runner_image.is_some() { | |
| 890 | 951 | saved.runner_image = local.runner_image.clone(); | |
| 891 | 952 | } | |
| 892 | − | // An update that stopped half-way left the registration here. | |
| 893 | − | let carried = dir.join("carry").join("config.json"); | |
| 894 | − | if let Ok(bytes) = std::fs::read(&carried) { | |
| 895 | − | backend.import_state(&bytes)?; | |
| 896 | − | let _ = std::fs::remove_file(&carried); | |
| 897 | − | } | |
| 898 | 953 | } | |
| 954 | + | // An update that stopped half-way, after the old system was | |
| 955 | + | // removed, left the registration here. | |
| 956 | + | let carried = dir.join("carry").join("config.json"); | |
| 957 | + | if let Ok(bytes) = std::fs::read(&carried) { | |
| 958 | + | backend.import_state(&bytes)?; | |
| 959 | + | let _ = std::fs::remove_dir_all(dir.join("carry")); | |
| 960 | + | } | |
| 899 | 961 | if let Some(tar) = options.value("runner-image-file") { | |
| 900 | 962 | backend.load_image(Path::new(tar))?; | |
| 901 | 963 | } | |
| ⋯ | |||
| 1063 | 1125 | log("Draining: the runner finishes the job it is running, then stops."); | |
| 1064 | 1126 | backend.stop(StopMode::Drain)?; | |
| 1065 | 1127 | } | |
| 1066 | − | // The registration, kept on the computer until the new sandbox has it. | |
| 1067 | − | let carried = dir.join("carry").join("config.json"); | |
| 1128 | + | // The registration, kept on the computer (in the private state | |
| 1129 | + | // folder) only while no sandbox has it: from just before the old | |
| 1130 | + | // system goes until the new one has it. Should the update stop in | |
| 1131 | + | // between, the next `up` puts it in the new system and removes it. | |
| 1132 | + | let carry = dir.join("carry"); | |
| 1133 | + | let carried = carry.join("config.json"); | |
| 1068 | 1134 | if let Some(bytes) = backend.export_state()? { | |
| 1069 | − | std::fs::create_dir_all(dir.join("carry"))?; | |
| 1135 | + | std::fs::create_dir_all(&carry)?; | |
| 1070 | 1136 | write_private(&carried, &bytes)?; | |
| 1071 | 1137 | } | |
| 1072 | − | backend.remove_system()?; | |
| 1138 | + | if let Err(error) = backend.remove_system() { | |
| 1139 | + | // The old system, and the registration in it, are still there. | |
| 1140 | + | let _ = std::fs::remove_dir_all(&carry); | |
| 1141 | + | return Err(error); | |
| 1142 | + | } | |
| 1073 | 1143 | backend.create(&local, &saved.resources)?; | |
| 1074 | 1144 | if let Ok(bytes) = std::fs::read(&carried) { | |
| 1075 | 1145 | backend.import_state(&bytes)?; | |
| 1076 | − | let _ = std::fs::remove_file(&carried); | |
| 1077 | 1146 | } | |
| 1147 | + | let _ = std::fs::remove_dir_all(&carry); | |
| 1078 | 1148 | if let Some(tar) = options.value("runner-image-file") { | |
| 1079 | 1149 | backend.load_image(Path::new(tar))?; | |
| 1080 | 1150 | } | |
| ⋯ | |||
| 1150 | 1220 | ..Status::default() | |
| 1151 | 1221 | }; | |
| 1152 | 1222 | let value = serde_json::to_value(&status).unwrap(); | |
| 1153 | − | for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] { | |
| 1223 | + | for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir", "warnings"] { | |
| 1154 | 1224 | assert!(value.get(field).is_some(), "{field}"); | |
| 1155 | 1225 | } | |
| 1156 | 1226 | for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix", "turned_off"] { | |
| ⋯ | |||
| 1214 | 1284 | } | |
| 1215 | 1285 | ||
| 1216 | 1286 | #[test] | |
| 1287 | + | fn a_host_mode_runner_of_the_same_account_is_warned_of() { | |
| 1288 | + | let root = std::env::temp_dir().join(format!("g1t-sandbox-host-{}", std::process::id())); | |
| 1289 | + | let registered = |name: &str, how: &str| { | |
| 1290 | + | let folder = root.join(name); | |
| 1291 | + | std::fs::create_dir_all(&folder).unwrap(); | |
| 1292 | + | let config = format!( | |
| 1293 | + | r#"{{"url":"https://g1t.sh","api":"https://api.g1t.sh","runner":"rnr_1","name":"a","credential":"g1tr_x","workspace":"acme","labels":[],"work_dir":"/tmp/w"{how}}}"# | |
| 1294 | + | ); | |
| 1295 | + | std::fs::write(folder.join("config.json"), config).unwrap(); | |
| 1296 | + | folder | |
| 1297 | + | }; | |
| 1298 | + | let host = registered("host", r#","docker":false"#); | |
| 1299 | + | let docker = registered("docker", ""); | |
| 1300 | + | let native = registered("native", r#","docker":false,"native":true"#); | |
| 1301 | + | let none = root.join("none"); | |
| 1302 | + | let found = host_mode_runners(&[host.clone(), docker, native, none]); | |
| 1303 | + | assert_eq!(found.len(), 1); | |
| 1304 | + | assert_eq!(found[0], host); | |
| 1305 | + | let said = host_mode_warning(&host); | |
| 1306 | + | assert!(said.contains("as you") && said.contains("g1t-runner remove --dir"), "{said}"); | |
| 1307 | + | // The desktop app's folder for running jobs directly is looked at. | |
| 1308 | + | assert!(host_runner_folders().iter().any(|f| f.ends_with(Path::new("sh.g1t.desktop").join("runner").join("host")))); | |
| 1309 | + | // An older status, without warnings, still reads. | |
| 1310 | + | let old: Status = serde_json::from_str(r#"{"schema":1,"state":"absent","backend":null,"runner_version":null,"launcher_version":"0.3.0","image_version":null,"isolation":null,"job":null,"error":null,"resources":{"cpus":0,"memory_mib":0,"disk_gib":0},"capabilities":[],"state_dir":"x"}"#).unwrap(); | |
| 1311 | + | assert!(old.warnings.is_empty()); | |
| 1312 | + | let _ = std::fs::remove_dir_all(&root); | |
| 1313 | + | } | |
| 1314 | + | ||
| 1315 | + | #[test] | |
| 1217 | 1316 | fn this_process_is_alive() { | |
| 1218 | 1317 | assert!(process_alive(std::process::id())); | |
| 1219 | 1318 | assert!(!process_alive(u32::MAX - 1)); | |
| 191 | 191 | Paths::in_dir(&self.dir.join("vm")) | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | /// Takes the registration token out of the seed disk, once the runner | |
| 195 | + | /// has registered with it. The guest reads the seed only as it boots, | |
| 196 | + | /// and a token it lacks then is one it no longer needs. | |
| 197 | + | fn forget_token(&self) { | |
| 198 | + | let seed = self.paths().seed; | |
| 199 | + | let Some(mut settings) = std::fs::read(&seed).ok().and_then(|bytes| guest::read_seed(&bytes).ok()) else { return }; | |
| 200 | + | if settings.token.take().is_some() | |
| 201 | + | && let Ok(bytes) = guest::write_seed(&settings, SEED_BYTES) | |
| 202 | + | { | |
| 203 | + | let _ = super::write_private(&seed, &bytes); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 194 | 207 | fn pid(&self) -> Option<u32> { | |
| 195 | 208 | std::fs::read_to_string(self.paths().pid).ok().and_then(|p| p.trim().parse().ok()) | |
| 196 | 209 | } | |
| ⋯ | |||
| 335 | 348 | let log = std::fs::read(self.paths().console).ok()?; | |
| 336 | 349 | // The console's tail is enough: statuses come every few seconds. | |
| 337 | 350 | let tail = &log[log.len().saturating_sub(64 * 1024)..]; | |
| 338 | − | guest::status_from_console(&String::from_utf8_lossy(tail)) | |
| 351 | + | let status = guest::status_from_console(&String::from_utf8_lossy(tail))?; | |
| 352 | + | // Registered: the token is no longer kept here either, as the | |
| 353 | + | // guest no longer keeps its own copy (guest.rs). | |
| 354 | + | if status.runner.is_some() { | |
| 355 | + | self.forget_token(); | |
| 356 | + | } | |
| 357 | + | Some(status) | |
| 339 | 358 | } | |
| 340 | 359 | ||
| 341 | 360 | fn stop(&self, mode: StopMode) -> Result<()> { | |
| ⋯ | |||
| 445 | 464 | } | |
| 446 | 465 | ||
| 447 | 466 | #[test] | |
| 467 | + | fn the_seed_lets_go_of_the_token_once_the_runner_registered() { | |
| 468 | + | let dir = std::env::temp_dir().join(format!("g1t-vm-seed-{}", std::process::id())); | |
| 469 | + | let vm = DiskVm { hypervisor: Hypervisor::Qemu, dir: dir.clone() }; | |
| 470 | + | std::fs::create_dir_all(dir.join("vm")).unwrap(); | |
| 471 | + | let settings = Settings { url: Some("https://g1t.sh".into()), token: Some("g1trt_x".into()), runner_image: "img@sha256:ab".into(), ..Settings::default() }; | |
| 472 | + | std::fs::write(vm.paths().seed, guest::write_seed(&settings, SEED_BYTES).unwrap()).unwrap(); | |
| 473 | + | // The console says the runner is registered and running. | |
| 474 | + | let status = GuestStatus { state: "running".into(), runner: Some(crate::selfhosted::RunnerStatus::default()), ..GuestStatus::default() }; | |
| 475 | + | std::fs::write(vm.paths().console, format!("boot\n{}{}\n", guest::STATUS_PREFIX, serde_json::to_string(&status).unwrap())).unwrap(); | |
| 476 | + | assert!(vm.guest_status().is_some_and(|s| s.runner.is_some())); | |
| 477 | + | let kept = guest::read_seed(&std::fs::read(vm.paths().seed).unwrap()).unwrap(); | |
| 478 | + | assert_eq!(kept.token, None); | |
| 479 | + | assert_eq!(kept.url.as_deref(), Some("https://g1t.sh")); | |
| 480 | + | assert_eq!(kept.runner_image, "img@sha256:ab"); | |
| 481 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | #[test] | |
| 448 | 485 | fn a_vm_needs_its_three_files() { | |
| 449 | 486 | let vm = DiskVm { hypervisor: Hypervisor::Vfkit, dir: PathBuf::from("/s") }; | |
| 450 | 487 | let keys = vm.image_keys(); | |
| 30 | 30 | mod exec; | |
| 31 | 31 | mod launcher; | |
| 32 | 32 | mod native; | |
| 33 | + | mod private; | |
| 33 | 34 | mod sandbox; | |
| 34 | 35 | mod service; | |
| 35 | 36 | mod update; | |
| ⋯ | |||
| 454 | 455 | } | |
| 455 | 456 | ||
| 456 | 457 | fn run(folder: &Path, once: bool) -> Result<()> { | |
| 458 | + | // Its folder holds its credential and, while a Windows job runs, that | |
| 459 | + | // job's script: a runner whose folder others can read does not start. | |
| 457 | 460 | let mut config = config::load(folder)?; | |
| 461 | + | private::folder(folder, "--dir")?; | |
| 458 | 462 | let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() }; | |
| 459 | 463 | std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?; | |
| 460 | 464 | let isolation = isolation(config.docker, config.native)?; | |
| 271 | 271 | folder.join("native").join("sandbox-id") | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | − | /// Stops the sandbox an earlier run of this runner left, if any. | |
| 274 | + | /// Stops the sandbox an earlier run of this runner left, if any, and | |
| 275 | + | /// removes the job folders it left: a runner stopped mid-job (a crash, or | |
| 276 | + | /// `taskkill /F`) never cleaned its own, and a job's script holds the | |
| 277 | + | /// job's token. | |
| 275 | 278 | pub fn sweep(folder: &Path) { | |
| 276 | 279 | let note = noted(folder); | |
| 277 | 280 | if let Ok(id) = std::fs::read_to_string(¬e) { | |
| ⋯ | |||
| 281 | 284 | } | |
| 282 | 285 | let _ = std::fs::remove_file(¬e); | |
| 283 | 286 | } | |
| 287 | + | for left in job_folders(&folder.join("native")) { | |
| 288 | + | let _ = std::fs::remove_dir_all(left); | |
| 289 | + | } | |
| 284 | 290 | } | |
| 285 | 291 | ||
| 292 | + | /// The job folders in a runner's `native` folder: `g1t-<job id>`, as | |
| 293 | + | /// exec.rs names them. | |
| 294 | + | pub fn job_folders(native: &Path) -> Vec<PathBuf> { | |
| 295 | + | let Ok(entries) = std::fs::read_dir(native) else { return Vec::new() }; | |
| 296 | + | entries | |
| 297 | + | .flatten() | |
| 298 | + | .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()) && entry.file_name().to_string_lossy().starts_with("g1t-")) | |
| 299 | + | .map(|entry| entry.path()) | |
| 300 | + | .collect() | |
| 301 | + | } | |
| 302 | + | ||
| 286 | 303 | /// The ids in `wsb list --raw`. | |
| 287 | 304 | pub fn parse_list(raw: &str) -> Vec<String> { | |
| 288 | 305 | let Some(start) = raw.find('{') else { return Vec::new() }; | |
| ⋯ | |||
| 387 | 404 | } | |
| 388 | 405 | ||
| 389 | 406 | #[test] | |
| 407 | + | fn a_runner_stopped_mid_job_has_its_job_folders_swept() { | |
| 408 | + | let folder = std::env::temp_dir().join(format!("g1t-native-sweep-{}", std::process::id())); | |
| 409 | + | let native = folder.join("native"); | |
| 410 | + | std::fs::create_dir_all(native.join("g1t-job_1")).unwrap(); | |
| 411 | + | std::fs::write(native.join("g1t-job_1").join("job.ps1"), "${env:ACTIONS_TOKEN} = 't'").unwrap(); | |
| 412 | + | std::fs::create_dir_all(native.join("native")).unwrap(); | |
| 413 | + | std::fs::write(native.join("runner.log"), "").unwrap(); | |
| 414 | + | assert_eq!(job_folders(&native), [native.join("g1t-job_1")]); | |
| 415 | + | // No sandbox was noted, so nothing is stopped; the job's folder goes. | |
| 416 | + | sweep(&folder); | |
| 417 | + | assert!(!native.join("g1t-job_1").exists()); | |
| 418 | + | assert!(native.join("native").exists() && native.join("runner.log").exists(), "only job folders"); | |
| 419 | + | let _ = std::fs::remove_dir_all(&folder); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[test] | |
| 390 | 423 | fn wsb_output_reads() { | |
| 391 | 424 | assert_eq!(parse_exit_code("{\n \"ExitCode\": 7\n}\n"), Some(7)); | |
| 392 | 425 | assert_eq!(parse_exit_code("nothing"), None); | |
| 1 | + | //! The runner's folders, kept to the account that runs it. A runner's | |
| 2 | + | //! folder holds its credential (`config.json`), a Windows job's script | |
| 3 | + | //! while the job runs, and a sandbox's disk; anyone else on the computer | |
| 4 | + | //! who could read them could act as the runner, or look at a job. | |
| 5 | + | //! | |
| 6 | + | //! On Unix a folder is `0700` and its files `0600`. On Windows a folder's | |
| 7 | + | //! permissions come from the folder it is made in: in the user's profile | |
| 8 | + | //! (the default, `~/.g1t-runner`) only its owner, SYSTEM and Administrators | |
| 9 | + | //! can read it, but one made at `C:\runner` (`--dir`, `G1T_RUNNER_DIR`, | |
| 10 | + | //! `--state-dir`) is readable by every user of the computer. So the folder | |
| 11 | + | //! is made private when it is not, and the runner refuses to use one that | |
| 12 | + | //! stays readable by others. | |
| 13 | + | ||
| 14 | + | use std::path::Path; | |
| 15 | + | ||
| 16 | + | use anyhow::{Context, Result, bail}; | |
| 17 | + | ||
| 18 | + | /// Writes a file only its owner can read, through a temporary file, so a | |
| 19 | + | /// crash never leaves half of it. On Windows its permissions are its | |
| 20 | + | /// folder's ([`folder`]). | |
| 21 | + | pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { | |
| 22 | + | let temp = path.with_extension("new"); | |
| 23 | + | std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?; | |
| 24 | + | #[cfg(unix)] | |
| 25 | + | { | |
| 26 | + | use std::os::unix::fs::PermissionsExt; | |
| 27 | + | std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?; | |
| 28 | + | } | |
| 29 | + | std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?; | |
| 30 | + | Ok(()) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Makes `dir` if it is not there, and makes sure only this account (and, | |
| 34 | + | /// on Windows, SYSTEM and Administrators) can read it: tightened if it is | |
| 35 | + | /// open to others, and refused if it stays so. `option` is how a person | |
| 36 | + | /// picks another folder (`--dir`, `--state-dir`). | |
| 37 | + | pub fn folder(dir: &Path, option: &str) -> Result<()> { | |
| 38 | + | std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?; | |
| 39 | + | let open = readers(dir); | |
| 40 | + | if open.is_empty() { | |
| 41 | + | return Ok(()); | |
| 42 | + | } | |
| 43 | + | let tightened = tighten(dir); | |
| 44 | + | let still = readers(dir); | |
| 45 | + | if still.is_empty() { | |
| 46 | + | return Ok(()); | |
| 47 | + | } | |
| 48 | + | let why = tightened.err().map(|e| format!(" ({e:#})")).unwrap_or_default(); | |
| 49 | + | bail!( | |
| 50 | + | "{} can be read by {}, and could not be made private to this account{why}. It holds the runner's credential: {}, or pick a folder in your own profile with {option}", | |
| 51 | + | dir.display(), | |
| 52 | + | still.join(", "), | |
| 53 | + | fix(dir) | |
| 54 | + | ) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Who other than this account can read `dir`, as a person names them; | |
| 58 | + | /// empty when no one. | |
| 59 | + | pub fn readers(dir: &Path) -> Vec<String> { | |
| 60 | + | #[cfg(unix)] | |
| 61 | + | { | |
| 62 | + | use std::os::unix::fs::PermissionsExt; | |
| 63 | + | let Ok(meta) = std::fs::metadata(dir) else { return Vec::new() }; | |
| 64 | + | unix_readers(meta.permissions().mode()) | |
| 65 | + | } | |
| 66 | + | #[cfg(windows)] | |
| 67 | + | { | |
| 68 | + | match windows::sddl(dir) { | |
| 69 | + | Some(sddl) => broad_readers(&sddl), | |
| 70 | + | None => Vec::new(), | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | #[cfg(unix)] | |
| 76 | + | fn tighten(dir: &Path) -> Result<()> { | |
| 77 | + | use std::os::unix::fs::PermissionsExt; | |
| 78 | + | std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(|| format!("could not chmod {}", dir.display())) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[cfg(windows)] | |
| 82 | + | fn tighten(dir: &Path) -> Result<()> { | |
| 83 | + | let Some(user) = windows::user_sid() else { bail!("could not tell which account this is") }; | |
| 84 | + | for args in icacls_args(&dir.display().to_string(), &user) { | |
| 85 | + | let mut command = std::process::Command::new("icacls"); | |
| 86 | + | command.args(&args).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped()); | |
| 87 | + | super::native::hide_window(&mut command); | |
| 88 | + | let output = command.output().context("could not run icacls")?; | |
| 89 | + | if !output.status.success() { | |
| 90 | + | bail!("icacls: {}", String::from_utf8_lossy(&output.stderr).trim()); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | Ok(()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | fn fix(dir: &Path) -> String { | |
| 97 | + | if cfg!(windows) { | |
| 98 | + | format!("in {}'s Properties, Security, Advanced, turn off inheritance and leave only yourself, SYSTEM and Administrators", dir.display()) | |
| 99 | + | } else { | |
| 100 | + | format!("run `chmod 700 {}` as its owner", dir.display()) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Who a Unix folder's mode lets in besides its owner. | |
| 105 | + | #[cfg_attr(not(unix), allow(dead_code))] | |
| 106 | + | fn unix_readers(mode: u32) -> Vec<String> { | |
| 107 | + | let mut who = Vec::new(); | |
| 108 | + | if mode & 0o040 != 0 { | |
| 109 | + | who.push("its group".to_owned()); | |
| 110 | + | } | |
| 111 | + | if mode & 0o004 != 0 { | |
| 112 | + | who.push("every user".to_owned()); | |
| 113 | + | } | |
| 114 | + | who | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// The well-known groups that mean every (or any) user of a Windows | |
| 118 | + | /// computer, by SID and by SDDL's short name. | |
| 119 | + | #[cfg_attr(not(windows), allow(dead_code))] | |
| 120 | + | const BROAD: &[(&str, &str, &str)] = &[ | |
| 121 | + | ("S-1-1-0", "WD", "Everyone"), | |
| 122 | + | ("S-1-5-32-545", "BU", "Users"), | |
| 123 | + | ("S-1-5-11", "AU", "Authenticated Users"), | |
| 124 | + | ("S-1-5-4", "IU", "Interactive users"), | |
| 125 | + | ("S-1-5-7", "AN", "Anonymous"), | |
| 126 | + | ("S-1-5-32-546", "BG", "Guests"), | |
| 127 | + | ]; | |
| 128 | + | ||
| 129 | + | /// Who of [`BROAD`] a folder's DACL, in SDDL (`D:PAI(A;OICI;FA;;;SY)…`), | |
| 130 | + | /// lets read it. A folder with no DACL at all lets everyone in. | |
| 131 | + | #[cfg_attr(not(windows), allow(dead_code))] | |
| 132 | + | fn broad_readers(sddl: &str) -> Vec<String> { | |
| 133 | + | let Some(start) = sddl.find("D:") else { return vec!["Everyone".to_owned()] }; | |
| 134 | + | let dacl = &sddl[start + 2..]; | |
| 135 | + | // The SACL, if it was asked for, follows the DACL. | |
| 136 | + | let dacl = dacl.find("S:").map_or(dacl, |end| &dacl[..end]); | |
| 137 | + | if dacl.starts_with("NO_ACCESS_CONTROL") { | |
| 138 | + | return vec!["Everyone".to_owned()]; | |
| 139 | + | } | |
| 140 | + | let mut who = Vec::new(); | |
| 141 | + | for ace in dacl.split('(').skip(1).filter_map(|rest| rest.split(')').next()) { | |
| 142 | + | let fields: Vec<&str> = ace.split(';').collect(); | |
| 143 | + | let [kind, _flags, rights, _, _, sid, ..] = fields[..] else { continue }; | |
| 144 | + | if !matches!(kind, "A" | "XA") || !reads(rights) { | |
| 145 | + | continue; | |
| 146 | + | } | |
| 147 | + | if let Some((_, _, name)) = BROAD.iter().find(|(long, short, _)| sid.eq_ignore_ascii_case(long) || sid == *short) | |
| 148 | + | && !who.iter().any(|w| w == name) | |
| 149 | + | { | |
| 150 | + | who.push((*name).to_owned()); | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | who | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Whether an ACE's rights, in SDDL, include reading a file's contents. | |
| 157 | + | #[cfg_attr(not(windows), allow(dead_code))] | |
| 158 | + | fn reads(rights: &str) -> bool { | |
| 159 | + | // FILE_READ_DATA, GENERIC_ALL, GENERIC_READ. | |
| 160 | + | const READ: u32 = 0x1 | 0x1000_0000 | 0x8000_0000; | |
| 161 | + | if let Some(hex) = rights.strip_prefix("0x").or_else(|| rights.strip_prefix("0X")) { | |
| 162 | + | return u32::from_str_radix(hex, 16).is_ok_and(|mask| mask & READ != 0); | |
| 163 | + | } | |
| 164 | + | if let Ok(mask) = rights.parse::<u32>() { | |
| 165 | + | return mask & READ != 0; | |
| 166 | + | } | |
| 167 | + | // Two letters a right: `FA` all, `FR` read, `GA`/`GR` generic all and | |
| 168 | + | // read, `CC` (bit 0, which for a file is reading its data). | |
| 169 | + | rights.as_bytes().chunks(2).any(|code| matches!(code, b"FA" | b"FR" | b"GA" | b"GR" | b"KA" | b"KR" | b"CC")) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// The `icacls` commands that leave a folder to `user`, SYSTEM and | |
| 173 | + | /// Administrators only, inherited by what is in it: no permissions from | |
| 174 | + | /// the folder above, and none for the groups that mean everyone. | |
| 175 | + | #[cfg_attr(not(windows), allow(dead_code))] | |
| 176 | + | fn icacls_args(dir: &str, user: &str) -> Vec<Vec<String>> { | |
| 177 | + | let mut grant = vec![dir.to_owned(), "/inheritance:r".into(), "/grant:r".into()]; | |
| 178 | + | for sid in ["S-1-5-18", "S-1-5-32-544", user] { | |
| 179 | + | grant.push(format!("*{sid}:(OI)(CI)F")); | |
| 180 | + | } | |
| 181 | + | let mut remove = vec![dir.to_owned(), "/remove:g".into()]; | |
| 182 | + | remove.extend(BROAD.iter().map(|(sid, _, _)| format!("*{sid}"))); | |
| 183 | + | vec![grant, remove] | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[cfg(windows)] | |
| 187 | + | mod windows { | |
| 188 | + | //! Reading a folder's permissions and this process's account, from | |
| 189 | + | //! Windows' own calls: SDDL and SIDs, which are the same in every | |
| 190 | + | //! language, where `icacls`' listing names groups in the computer's. | |
| 191 | + | ||
| 192 | + | use std::os::windows::ffi::OsStrExt; | |
| 193 | + | use std::path::Path; | |
| 194 | + | use std::ptr::null_mut; | |
| 195 | + | ||
| 196 | + | type Handle = *mut core::ffi::c_void; | |
| 197 | + | ||
| 198 | + | const SE_FILE_OBJECT: i32 = 1; | |
| 199 | + | const DACL_SECURITY_INFORMATION: u32 = 4; | |
| 200 | + | const SDDL_REVISION_1: u32 = 1; | |
| 201 | + | const TOKEN_QUERY: u32 = 8; | |
| 202 | + | const TOKEN_USER: i32 = 1; | |
| 203 | + | ||
| 204 | + | #[link(name = "advapi32")] | |
| 205 | + | unsafe extern "system" { | |
| 206 | + | fn GetNamedSecurityInfoW( | |
| 207 | + | name: *const u16, | |
| 208 | + | object: i32, | |
| 209 | + | info: u32, | |
| 210 | + | owner: *mut Handle, | |
| 211 | + | group: *mut Handle, | |
| 212 | + | dacl: *mut Handle, | |
| 213 | + | sacl: *mut Handle, | |
| 214 | + | descriptor: *mut Handle, | |
| 215 | + | ) -> u32; | |
| 216 | + | fn ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor: Handle, revision: u32, info: u32, text: *mut *mut u16, length: *mut u32) -> i32; | |
| 217 | + | fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; | |
| 218 | + | fn GetTokenInformation(token: Handle, class: i32, info: *mut core::ffi::c_void, length: u32, returned: *mut u32) -> i32; | |
| 219 | + | fn ConvertSidToStringSidW(sid: Handle, text: *mut *mut u16) -> i32; | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | unsafe extern "system" { | |
| 223 | + | fn LocalFree(memory: Handle) -> Handle; | |
| 224 | + | fn GetCurrentProcess() -> Handle; | |
| 225 | + | fn CloseHandle(handle: Handle) -> i32; | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// A string Windows allocated, read and freed. | |
| 229 | + | unsafe fn take(text: *mut u16) -> String { | |
| 230 | + | let mut length = 0; | |
| 231 | + | while unsafe { *text.add(length) } != 0 { | |
| 232 | + | length += 1; | |
| 233 | + | } | |
| 234 | + | let read = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) }); | |
| 235 | + | unsafe { LocalFree(text.cast()) }; | |
| 236 | + | read | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// A file or folder's DACL, in SDDL. | |
| 240 | + | pub fn sddl(path: &Path) -> Option<String> { | |
| 241 | + | let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect(); | |
| 242 | + | let mut descriptor: Handle = null_mut(); | |
| 243 | + | let error = unsafe { | |
| 244 | + | GetNamedSecurityInfoW(wide.as_ptr(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, null_mut(), null_mut(), null_mut(), null_mut(), &mut descriptor) | |
| 245 | + | }; | |
| 246 | + | if error != 0 || descriptor.is_null() { | |
| 247 | + | return None; | |
| 248 | + | } | |
| 249 | + | let mut text: *mut u16 = null_mut(); | |
| 250 | + | let converted = unsafe { ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &mut text, null_mut()) }; | |
| 251 | + | unsafe { LocalFree(descriptor) }; | |
| 252 | + | (converted != 0 && !text.is_null()).then(|| unsafe { take(text) }) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// This process's account's SID: `S-1-5-21-…`. | |
| 256 | + | pub fn user_sid() -> Option<String> { | |
| 257 | + | let mut token: Handle = null_mut(); | |
| 258 | + | if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { | |
| 259 | + | return None; | |
| 260 | + | } | |
| 261 | + | // TOKEN_USER and the SID after it; usize keeps its pointer aligned. | |
| 262 | + | let mut buffer = vec![0usize; 64]; | |
| 263 | + | let mut returned = 0u32; | |
| 264 | + | let ok = unsafe { GetTokenInformation(token, TOKEN_USER, buffer.as_mut_ptr().cast(), (buffer.len() * size_of::<usize>()) as u32, &mut returned) }; | |
| 265 | + | unsafe { CloseHandle(token) }; | |
| 266 | + | if ok == 0 { | |
| 267 | + | return None; | |
| 268 | + | } | |
| 269 | + | // TOKEN_USER begins with the SID's pointer. | |
| 270 | + | let sid = buffer[0] as Handle; | |
| 271 | + | let mut text: *mut u16 = null_mut(); | |
| 272 | + | if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 || text.is_null() { | |
| 273 | + | return None; | |
| 274 | + | } | |
| 275 | + | Some(unsafe { take(text) }) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[cfg(test)] | |
| 280 | + | mod tests { | |
| 281 | + | use super::*; | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn a_folder_open_to_every_user_is_found_by_its_groups() { | |
| 285 | + | // C:\runner, made under C:\: Users read it, Authenticated Users change it. | |
| 286 | + | let open = "D:AI(A;OICIID;FA;;;BA)(A;OICIID;FA;;;SY)(A;OICIID;0x1200a9;;;BU)(A;ID;0x1301bf;;;AU)(A;OICIIOID;SDGXGWGR;;;AU)"; | |
| 287 | + | assert_eq!(broad_readers(open), ["Users", "Authenticated Users"]); | |
| 288 | + | // The user's profile, or a folder made private. | |
| 289 | + | let private = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)"; | |
| 290 | + | assert!(broad_readers(private).is_empty()); | |
| 291 | + | // Everyone, by SID; and a right to list and traverse only, which reads nothing. | |
| 292 | + | assert_eq!(broad_readers("D:(A;;0x120089;;;S-1-1-0)"), ["Everyone"]); | |
| 293 | + | assert!(broad_readers("D:(A;;0x100020;;;WD)").is_empty()); | |
| 294 | + | // A deny is no way in, and no DACL at all is every way in. | |
| 295 | + | assert!(broad_readers("D:(D;;FA;;;WD)").is_empty()); | |
| 296 | + | assert_eq!(broad_readers("D:NO_ACCESS_CONTROL"), ["Everyone"]); | |
| 297 | + | assert_eq!(broad_readers("O:BAG:SY"), ["Everyone"]); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn rights_that_read_a_file_are_told_from_those_that_do_not() { | |
| 302 | + | assert!(reads("FA") && reads("FR") && reads("GR") && reads("0x1200a9") && reads("0x80000000") && reads("1")); | |
| 303 | + | assert!(!reads("FX") && !reads("SDGXGW") && !reads("0x100020") && !reads("")); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn a_folder_is_left_to_its_owner_system_and_administrators() { | |
| 308 | + | let [grant, remove] = &icacls_args(r"C:\runner", "S-1-5-21-1-2-3-1001")[..] else { panic!() }; | |
| 309 | + | assert_eq!(grant, &[r"C:\runner", "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-21-1-2-3-1001:(OI)(CI)F"]); | |
| 310 | + | assert_eq!(remove[..2], [r"C:\runner", "/remove:g"]); | |
| 311 | + | for sid in ["*S-1-1-0", "*S-1-5-32-545", "*S-1-5-11"] { | |
| 312 | + | assert!(remove.iter().any(|a| a == sid), "{sid}"); | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn a_unix_folder_is_open_when_its_group_or_others_may_read_it() { | |
| 318 | + | assert!(unix_readers(0o700).is_empty()); | |
| 319 | + | assert!(unix_readers(0o711).is_empty()); | |
| 320 | + | assert_eq!(unix_readers(0o750), ["its group"]); | |
| 321 | + | assert_eq!(unix_readers(0o755), ["its group", "every user"]); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | #[test] | |
| 325 | + | fn a_folder_the_runner_makes_is_private() { | |
| 326 | + | let dir = std::env::temp_dir().join(format!("g1t-private-{}", std::process::id())).join("state"); | |
| 327 | + | folder(&dir, "--dir").unwrap(); | |
| 328 | + | assert!(readers(&dir).is_empty(), "{:?}", readers(&dir)); | |
| 329 | + | write_private(&dir.join("config.json"), b"{}").unwrap(); | |
| 330 | + | assert_eq!(std::fs::read(dir.join("config.json")).unwrap(), b"{}"); | |
| 331 | + | #[cfg(unix)] | |
| 332 | + | { | |
| 333 | + | use std::os::unix::fs::PermissionsExt; | |
| 334 | + | assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); | |
| 335 | + | assert_eq!(std::fs::metadata(dir.join("config.json")).unwrap().permissions().mode() & 0o777, 0o600); | |
| 336 | + | } | |
| 337 | + | #[cfg(windows)] | |
| 338 | + | assert!(windows::user_sid().is_some_and(|sid| sid.starts_with("S-1-"))); | |
| 339 | + | let _ = std::fs::remove_dir_all(dir.parent().unwrap()); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | /// Makes a folder every user can read and tightens it: run with | |
| 343 | + | /// `cargo test -p g1t-runner -- --ignored a_folder_every_user_can_read` | |
| 344 | + | /// on Windows (it uses `C:\Users\Public`). | |
| 345 | + | #[test] | |
| 346 | + | #[ignore] | |
| 347 | + | #[cfg(windows)] | |
| 348 | + | fn a_folder_every_user_can_read_is_made_private() { | |
| 349 | + | let dir = std::path::PathBuf::from(format!(r"C:\Users\Public\g1t-private-{}", std::process::id())); | |
| 350 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 351 | + | assert!(!readers(&dir).is_empty(), "Public is open to every user"); | |
| 352 | + | folder(&dir, "--dir").unwrap(); | |
| 353 | + | assert!(readers(&dir).is_empty(), "{:?}", windows::sddl(&dir)); | |
| 354 | + | let sddl = windows::sddl(&dir).unwrap(); | |
| 355 | + | assert!(sddl.starts_with("D:P"), "no permissions from the folder above: {sddl}"); | |
| 356 | + | std::fs::write(dir.join("config.json"), "{}").unwrap(); | |
| 357 | + | assert!(readers(&dir.join("config.json")).is_empty(), "{:?}", windows::sddl(&dir.join("config.json"))); | |
| 358 | + | std::fs::remove_dir_all(&dir).unwrap(); | |
| 359 | + | } | |
| 360 | + | } |
| 50 | 50 | RUN echo "$IMAGE_VERSION" > /etc/g1t-sandbox-version | |
| 51 | 51 | ||
| 52 | 52 | FROM base AS wsl | |
| 53 | + | # WSL's default user (wsl.conf): a locked account with no shell and no | |
| 54 | + | # home, so whatever opens the distribution without naming a user (Explorer | |
| 55 | + | # at \\wsl.localhost\g1t-sandbox, a bare `wsl -d g1t-sandbox`) is not root | |
| 56 | + | # and cannot read the runner's registration. The launcher names root itself. | |
| 57 | + | RUN useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox \ | |
| 58 | + | && passwd --lock sandbox | |
| 53 | 59 | COPY wsl.conf /etc/wsl.conf | |
| 54 | 60 | ||
| 55 | 61 | FROM base AS vm |
| 15 | 15 | [boot] | |
| 16 | 16 | systemd = false | |
| 17 | 17 | ||
| 18 | + | # Every call the launcher makes names root itself (`wsl -u root`). Anything | |
| 19 | + | # else that opens the distribution, a `wsl -d g1t-sandbox` with no user or | |
| 20 | + | # Explorer at \\wsl.localhost\g1t-sandbox, gets this account instead: no | |
| 21 | + | # password, no shell, no home, and none of root's files (the Dockerfile). | |
| 18 | 22 | [user] | |
| 19 | − | default = root | |
| 23 | + | default = sandbox | |
| 20 | 24 | ||
| 21 | 25 | # WSL's resolv.conf is a link into /mnt/wsl, which the sandbox unmounts: | |
| 22 | 26 | # the supervisor writes its own from what WSL offers at start. |
| 30 | 30 | /// The most a single log report may add. | |
| 31 | 31 | const MAX_CHUNK_BYTES: usize = 256 * 1024; | |
| 32 | 32 | const MAX_ANNOTATIONS: usize = 50; | |
| 33 | + | /// Swaps a running job's token for a new one, only while it still has the | |
| 34 | + | /// one the caller showed (`job_spec` with `rotate`). | |
| 35 | + | const TOKEN_ROTATE: &str = "UPDATE jobs SET token_hash = ?1 WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id"; | |
| 33 | 36 | /// A batch of a job's log, under the next `seq` of that job's, worked out | |
| 34 | 37 | /// in the same statement (SQLite runs one write at a time). `?4` is the | |
| 35 | 38 | /// runner's number for the batch (null from runners that send none): a | |
| ⋯ | |||
| 2276 | 2279 | /// `job_spec`: everything the sandbox needs to run the job. | |
| 2277 | 2280 | pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2278 | 2281 | let job = check!(self.job_for_token(&a).await?); | |
| 2282 | + | // Asked to, the token the harness asked with is spent here: it gets | |
| 2283 | + | // a new one with the spec, for everything after, so a copy of the | |
| 2284 | + | // first left on the runner's computer (a Windows job's script, a | |
| 2285 | + | // container's environment) cannot fetch the secrets again. The new | |
| 2286 | + | // token is made now, as the runtime token is signed with its hash, | |
| 2287 | + | // and swapped in last, so a spec that could not be made leaves the | |
| 2288 | + | // old one working. | |
| 2289 | + | let next = a.rotate.then(|| random_hex(24)); | |
| 2290 | + | let token_hash = match &next { | |
| 2291 | + | Some(next) => sha256_hex(next), | |
| 2292 | + | None => job.token_hash.clone().unwrap_or_default(), | |
| 2293 | + | }; | |
| 2279 | 2294 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 2280 | 2295 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 2281 | 2296 | }; | |
| ⋯ | |||
| 2398 | 2413 | let runtime_token = crate::runtime::runtime_token( | |
| 2399 | 2414 | &job.id, | |
| 2400 | 2415 | &job.run_id, | |
| 2401 | − | job.token_hash.as_deref().unwrap_or_default(), | |
| 2416 | + | &token_hash, | |
| 2402 | 2417 | now_ms() / 1000, | |
| 2403 | 2418 | u64::from(job.timeout_minutes) * 60 + 600, | |
| 2404 | 2419 | ); | |
| ⋯ | |||
| 2469 | 2484 | _ => format!("{SITE}/{}.git", run.repo), | |
| 2470 | 2485 | }; | |
| 2471 | 2486 | ||
| 2472 | − | Ok(Outcome::Ok(json!({ | |
| 2487 | + | // Only one fetch with a token gets its spec: of two at once, the | |
| 2488 | + | // second finds the hash already changed and is refused. | |
| 2489 | + | if next.is_some() { | |
| 2490 | + | let swapped = self | |
| 2491 | + | .db | |
| 2492 | + | .prepare(TOKEN_ROTATE) | |
| 2493 | + | .bind(&[token_hash.as_str().into(), job.id.as_str().into(), job.token_hash.as_deref().unwrap_or_default().into()])? | |
| 2494 | + | .first::<Value>(None) | |
| 2495 | + | .await?; | |
| 2496 | + | if swapped.is_none() { | |
| 2497 | + | return Ok(fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its.")); | |
| 2498 | + | } | |
| 2499 | + | } | |
| 2500 | + | ||
| 2501 | + | let mut spec = json!({ | |
| 2473 | 2502 | "job": job.id, | |
| 2474 | 2503 | "run": run.id, | |
| 2475 | 2504 | "key": job.key, | |
| ⋯ | |||
| 2513 | 2542 | "token": runtime_token, | |
| 2514 | 2543 | "idToken": self.oidc_allowed(&run, &job), | |
| 2515 | 2544 | }, | |
| 2516 | − | }))) | |
| 2545 | + | }); | |
| 2546 | + | if let Some(next) = next { | |
| 2547 | + | spec["nextToken"] = json!(next); | |
| 2548 | + | } | |
| 2549 | + | Ok(Outcome::Ok(spec)) | |
| 2517 | 2550 | } | |
| 2518 | 2551 | ||
| 2519 | 2552 | /// `job_report`: the sandbox telling how the job is going. | |
| ⋯ | |||
| 2839 | 2872 | } | |
| 2840 | 2873 | ||
| 2841 | 2874 | #[cfg(test)] | |
| 2875 | + | mod token_rotation { | |
| 2876 | + | use g1t_secrets::sha256_hex; | |
| 2877 | + | use rusqlite::{Connection, OptionalExtension, params}; | |
| 2878 | + | ||
| 2879 | + | use super::TOKEN_ROTATE; | |
| 2880 | + | ||
| 2881 | + | fn database() -> Connection { | |
| 2882 | + | let db = Connection::open_in_memory().unwrap(); | |
| 2883 | + | let path = format!("{}/migrations/0001_init.sql", env!("CARGO_MANIFEST_DIR")); | |
| 2884 | + | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 2885 | + | db.execute( | |
| 2886 | + | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, token_hash) VALUES ('job_1', 'run_1', 'repo_1', 'acme', 'build', 'build', 'in_progress', ?1)", | |
| 2887 | + | [sha256_hex("first")], | |
| 2888 | + | ) | |
| 2889 | + | .unwrap(); | |
| 2890 | + | db | |
| 2891 | + | } | |
| 2892 | + | ||
| 2893 | + | fn rotate(db: &Connection, next: &str, shown: &str) -> bool { | |
| 2894 | + | db.query_row(TOKEN_ROTATE, params![sha256_hex(next), "job_1", sha256_hex(shown)], |row| row.get::<_, String>(0)) | |
| 2895 | + | .optional() | |
| 2896 | + | .unwrap() | |
| 2897 | + | .is_some() | |
| 2898 | + | } | |
| 2899 | + | ||
| 2900 | + | fn hash(db: &Connection) -> Option<String> { | |
| 2901 | + | db.query_row("SELECT token_hash FROM jobs WHERE id = 'job_1'", [], |row| row.get(0)).unwrap() | |
| 2902 | + | } | |
| 2903 | + | ||
| 2904 | + | #[test] | |
| 2905 | + | fn a_job_token_fetches_its_spec_once() { | |
| 2906 | + | let db = database(); | |
| 2907 | + | assert!(rotate(&db, "second", "first")); | |
| 2908 | + | assert_eq!(hash(&db), Some(sha256_hex("second"))); | |
| 2909 | + | // The first token, shown again (a copy left on the computer, or a | |
| 2910 | + | // second fetch racing the first), swaps nothing and is refused. | |
| 2911 | + | assert!(!rotate(&db, "third", "first")); | |
| 2912 | + | assert_eq!(hash(&db), Some(sha256_hex("second"))); | |
| 2913 | + | // A job that ended has no token to swap. | |
| 2914 | + | db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL", []).unwrap(); | |
| 2915 | + | assert!(!rotate(&db, "fourth", "second")); | |
| 2916 | + | assert_eq!(hash(&db), None); | |
| 2917 | + | } | |
| 2918 | + | ||
| 2919 | + | #[test] | |
| 2920 | + | fn the_runtime_token_is_signed_for_the_token_that_replaces_the_first() { | |
| 2921 | + | let now = 1_800_000_000; | |
| 2922 | + | let token = crate::runtime::runtime_token("job_1", "run_1", &sha256_hex("second"), now, 600); | |
| 2923 | + | assert!(crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("second"), now)); | |
| 2924 | + | assert!(!crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("first"), now)); | |
| 2925 | + | } | |
| 2926 | + | } | |
| 2927 | + | ||
| 2928 | + | #[cfg(test)] | |
| 2842 | 2929 | mod stopping { | |
| 2843 | 2930 | use super::stops_runs; | |
| 2844 | 2931 | use g1t_contracts::events::Event; | |
Comments
Sign in to comment.