Merge the self-hosted runner's host-side hardening: a job's token is spent once its spec is fetched (rotated, with brokered secrets still found by the token the job started with), job folders a crashed runner left are removed, the runner's folders are owner-only on Windows and Unix, the sandbox warns of a host-mode runner under the same account, its WSL default user is a locked account rather than root, a VM's seed no longer keeps the registration token, and the self-hosted runners guide says what your computer can see.
20 files+896−690/20 viewed
| 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 everything that can be asked through g1t's | |
| 346 | + | API (inside g1t, only the lookup of [brokered | |
| 347 | + | secrets](/guides/secrets-and-variables/#brokered-secrets) still takes it, and that is not reachable | |
| 348 | + | from your computer or a job). A copy left | |
| 349 | + | behind can't fetch the job, its secrets or a `G1T_TOKEN` again. A job's | |
| 350 | + | folder is deleted when the job ends, and a runner that was stopped | |
| 351 | + | mid-job (a crash, or `taskkill`) deletes what it left when it next | |
| 352 | + | starts. | |
| 353 | + | - **The registration token goes once the runner has registered.** It is | |
| 354 | + | kept only until the runner inside has registered with it; then the | |
| 355 | + | launcher takes it out of the VM's seed disk on macOS and Linux, and the | |
| 356 | + | sandbox out of its own settings. On Windows, an `update` keeps the | |
| 357 | + | runner's registration on your computer only while the VM is swapped. | |
| 358 | + | - **The state folders are yours alone.** The runner's folder | |
| 359 | + | (`~/.g1t-runner`, `--dir` or `G1T_RUNNER_DIR`) and the sandbox's | |
| 360 | + | (`--state-dir`) hold the runners' credentials, a Windows job's script | |
| 361 | + | while it runs, and the VM's disk. On macOS and Linux they are made | |
| 362 | + | `0700` and their files `0600`. On Windows a folder in your profile is | |
| 363 | + | already yours alone; one elsewhere, such as `C:\runner`, would let every | |
| 364 | + | user of the computer read it, so the runner makes it private (you, | |
| 365 | + | SYSTEM and Administrators, and nothing inherited from the folder | |
| 366 | + | above). If it can't, it refuses to start and says how to fix it. | |
| 367 | + | - **On Windows, opening the sandbox's distribution without naming a user** | |
| 368 | + | (Explorer at `\\wsl.localhost\g1t-sandbox`, or `wsl -d g1t-sandbox`) | |
| 369 | + | gets a locked account with no shell, which can't read the runner's | |
| 370 | + | registration. The launcher names root itself for what it does there. | |
| 371 | + | ||
| 316 | 372 | ### The status as JSON | |
| 317 | 373 | ||
| 318 | 374 | `g1t-runner sandbox status --json` prints one JSON object, which [the | |
| ⋯ | |||
| 341 | 397 | "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 342 | 398 | "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." } | |
| 343 | 399 | ], | |
| 344 | − | "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox" | |
| 400 | + | "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox", | |
| 401 | + | "warnings": [] | |
| 345 | 402 | } | |
| 346 | 403 | ``` | |
| 347 | 404 | ||
| ⋯ | |||
| 354 | 411 | | `error` | A sentence for a person when something is wrong, or null. | | |
| 355 | 412 | | `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 | 413 | | `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`. | | |
| 414 | + | | `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 | 415 | ||
| 358 | 416 | Fields are only added; `schema` changes if one changes meaning. | |
| 359 | 417 | ||
| ⋯ | |||
| 661 | 719 | given and remove itself, and nothing else; every other endpoint refuses | |
| 662 | 720 | it. It is kept only as a hash on g1t's side, rotates every day, and stops | |
| 663 | 721 | working the moment the runner is removed. It is never given to a job: | |
| 664 | − | each job gets only its own short-lived token. | |
| 722 | + | each job gets only its own short-lived token, which the job spends on | |
| 723 | + | fetching itself and replaces with one only the job has. The runner's | |
| 724 | + | folder is its owner's alone, and the runner refuses to start in one | |
| 725 | + | other users can read ([what your computer can see](#what-your-computer-can-see)). | |
| 665 | 726 | - **Isolation**: run the runner's image under Sysbox. Each job then gets | |
| 666 | 727 | a container of its own inside the runner's container, removed after, | |
| 667 | 728 | 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. |
| 1 | + | -- A self-hosted runner's harness spends the job's token on fetching the | |
| 2 | + | -- job, and gets a new one in its place (plan.rs `job_spec` with `rotate`). | |
| 3 | + | -- The token the job started with stops working for everything but | |
| 4 | + | -- `brokered_secrets`, which the runner Worker asks with the token it was | |
| 5 | + | -- given at the start and which no API route passes on: its hash is kept | |
| 6 | + | -- here from the swap until the job ends or is claimed again. | |
| 7 | + | ALTER TABLE jobs ADD COLUMN first_token_hash TEXT; |
| 186 | 186 | /// `brokered_secrets`: a running job's stand-ins and what they stand | |
| 187 | 187 | /// for, for the runner Worker to fill in on the job's requests. Asked | |
| 188 | 188 | /// with the job's own token, which only the runner and the job hold; | |
| 189 | − | /// the API never passes this method on, so the job cannot ask it. | |
| 189 | + | /// the API never passes this method on, so the job cannot ask it. The | |
| 190 | + | /// Worker holds the token the job started with, so that one still | |
| 191 | + | /// answers here after the job's harness swapped it for another | |
| 192 | + | /// (`job_spec` with `rotate`), and nowhere else. | |
| 190 | 193 | pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> { | |
| 191 | − | let job = check!(self.job_for_token(&a).await?); | |
| 194 | + | let job = check!(self.job_for_started_token(&a).await?); | |
| 192 | 195 | Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?)) | |
| 193 | 196 | } | |
| 194 | 197 | } |
| 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`), keeping the hash of | |
| 35 | + | /// the one it started with for `brokered_secrets` (migration 0020). | |
| 36 | + | const TOKEN_ROTATE: &str = "UPDATE jobs SET first_token_hash = COALESCE(first_token_hash, token_hash), token_hash = ?1 | |
| 37 | + | WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id"; | |
| 38 | + | ||
| 39 | + | /// Whether `token` is a running job's: its current one, or with `started` | |
| 40 | + | /// (`brokered_secrets` only) the one it started with before its harness | |
| 41 | + | /// swapped it. | |
| 42 | + | fn token_opens(current: Option<&str>, first: Option<&str>, token: &str, started: bool) -> bool { | |
| 43 | + | let hash = sha256_hex(token); | |
| 44 | + | current.is_some_and(|h| same(h, &hash)) || (started && first.is_some_and(|h| same(h, &hash))) | |
| 45 | + | } | |
| 33 | 46 | /// A batch of a job's log, under the next `seq` of that job's, worked out | |
| 34 | 47 | /// in the same statement (SQLite runs one write at a time). `?4` is the | |
| 35 | 48 | /// runner's number for the batch (null from runners that send none): a | |
| ⋯ | |||
| 236 | 249 | /// 0019), so its log is masked for their values too. | |
| 237 | 250 | #[serde(default)] | |
| 238 | 251 | pub brokered: u32, | |
| 252 | + | /// The hash of the token it started with, once its harness swapped that | |
| 253 | + | /// for another (migration 0020): good for `brokered_secrets` only. | |
| 254 | + | #[serde(default)] | |
| 255 | + | pub first_token_hash: Option<String>, | |
| 239 | 256 | } | |
| 240 | 257 | ||
| 241 | 258 | /// How long a cancelled job has to run its `if: always()` and `cancelled()` | |
| ⋯ | |||
| 1608 | 1625 | let claimed = self | |
| 1609 | 1626 | .db | |
| 1610 | 1627 | .prepare( | |
| 1611 | − | "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id", | |
| 1628 | + | "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id", | |
| 1612 | 1629 | ) | |
| 1613 | 1630 | .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])? | |
| 1614 | 1631 | .first::<Value>(None) | |
| ⋯ | |||
| 1675 | 1692 | .db | |
| 1676 | 1693 | .prepare( | |
| 1677 | 1694 | "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs), | |
| 1678 | − | finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *", | |
| 1695 | + | finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *", | |
| 1679 | 1696 | ) | |
| 1680 | 1697 | .bind(&[ | |
| 1681 | 1698 | conclusion.into(), | |
| ⋯ | |||
| 1783 | 1800 | } | |
| 1784 | 1801 | let stopped = self | |
| 1785 | 1802 | .db | |
| 1786 | − | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *") | |
| 1803 | + | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *") | |
| 1787 | 1804 | .bind(&[reason.into(), now().into(), job.id.as_str().into()])? | |
| 1788 | 1805 | .first::<JobRow>(None) | |
| 1789 | 1806 | .await?; | |
| ⋯ | |||
| 2216 | 2233 | self.db | |
| 2217 | 2234 | .prepare( | |
| 2218 | 2235 | "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL, | |
| 2219 | − | matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL, | |
| 2236 | + | matrix = NULL, call = NULL, token_hash = NULL, first_token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL, | |
| 2220 | 2237 | labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL, | |
| 2221 | 2238 | concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?", | |
| 2222 | 2239 | ) | |
| ⋯ | |||
| 2257 | 2274 | // --- The sandbox's side ----------------------------------------------------- | |
| 2258 | 2275 | ||
| 2259 | 2276 | pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> { | |
| 2277 | + | self.job_for(a, false).await | |
| 2278 | + | } | |
| 2279 | + | ||
| 2280 | + | /// As [`Self::job_for_token`], and also with the token the job started | |
| 2281 | + | /// with after its harness swapped it (`job_spec` with `rotate`): for | |
| 2282 | + | /// `brokered_secrets` alone, which the runner Worker asks with the | |
| 2283 | + | /// token it was given at the start, and which no API route passes on. | |
| 2284 | + | pub(crate) async fn job_for_started_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> { | |
| 2285 | + | self.job_for(a, true).await | |
| 2286 | + | } | |
| 2287 | + | ||
| 2288 | + | async fn job_for(&self, a: &JobCallArgs, started: bool) -> Result<Outcome<JobRow>> { | |
| 2260 | 2289 | let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?; | |
| 2261 | 2290 | Ok(match job { | |
| 2262 | − | Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => { | |
| 2263 | − | Outcome::Ok(job) | |
| 2264 | − | } | |
| 2291 | + | Some(job) if job.status == "in_progress" && token_opens(job.token_hash.as_deref(), job.first_token_hash.as_deref(), &a.token, started) => Outcome::Ok(job), | |
| 2265 | 2292 | _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."), | |
| 2266 | 2293 | }) | |
| 2267 | 2294 | } | |
| ⋯ | |||
| 2276 | 2303 | /// `job_spec`: everything the sandbox needs to run the job. | |
| 2277 | 2304 | pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2278 | 2305 | let job = check!(self.job_for_token(&a).await?); | |
| 2306 | + | // Asked to, the token the harness asked with is spent here: it gets | |
| 2307 | + | // a new one with the spec, for everything after, so a copy of the | |
| 2308 | + | // first left on the runner's computer (a Windows job's script, a | |
| 2309 | + | // container's environment) cannot fetch the secrets again. The new | |
| 2310 | + | // token is made now, as the runtime token is signed with its hash, | |
| 2311 | + | // and swapped in last, so a spec that could not be made leaves the | |
| 2312 | + | // old one working. | |
| 2313 | + | let next = a.rotate.then(|| random_hex(24)); | |
| 2314 | + | let token_hash = match &next { | |
| 2315 | + | Some(next) => sha256_hex(next), | |
| 2316 | + | None => job.token_hash.clone().unwrap_or_default(), | |
| 2317 | + | }; | |
| 2279 | 2318 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 2280 | 2319 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 2281 | 2320 | }; | |
| ⋯ | |||
| 2398 | 2437 | let runtime_token = crate::runtime::runtime_token( | |
| 2399 | 2438 | &job.id, | |
| 2400 | 2439 | &job.run_id, | |
| 2401 | − | job.token_hash.as_deref().unwrap_or_default(), | |
| 2440 | + | &token_hash, | |
| 2402 | 2441 | now_ms() / 1000, | |
| 2403 | 2442 | u64::from(job.timeout_minutes) * 60 + 600, | |
| 2404 | 2443 | ); | |
| ⋯ | |||
| 2469 | 2508 | _ => format!("{SITE}/{}.git", run.repo), | |
| 2470 | 2509 | }; | |
| 2471 | 2510 | ||
| 2472 | − | Ok(Outcome::Ok(json!({ | |
| 2511 | + | // Only one fetch with a token gets its spec: of two at once, the | |
| 2512 | + | // second finds the hash already changed and is refused. | |
| 2513 | + | if next.is_some() { | |
| 2514 | + | let swapped = self | |
| 2515 | + | .db | |
| 2516 | + | .prepare(TOKEN_ROTATE) | |
| 2517 | + | .bind(&[token_hash.as_str().into(), job.id.as_str().into(), job.token_hash.as_deref().unwrap_or_default().into()])? | |
| 2518 | + | .first::<Value>(None) | |
| 2519 | + | .await?; | |
| 2520 | + | if swapped.is_none() { | |
| 2521 | + | return Ok(fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its.")); | |
| 2522 | + | } | |
| 2523 | + | } | |
| 2524 | + | ||
| 2525 | + | let mut spec = json!({ | |
| 2473 | 2526 | "job": job.id, | |
| 2474 | 2527 | "run": run.id, | |
| 2475 | 2528 | "key": job.key, | |
| ⋯ | |||
| 2513 | 2566 | "token": runtime_token, | |
| 2514 | 2567 | "idToken": self.oidc_allowed(&run, &job), | |
| 2515 | 2568 | }, | |
| 2516 | − | }))) | |
| 2569 | + | }); | |
| 2570 | + | if let Some(next) = next { | |
| 2571 | + | spec["nextToken"] = json!(next); | |
| 2572 | + | } | |
| 2573 | + | Ok(Outcome::Ok(spec)) | |
| 2517 | 2574 | } | |
| 2518 | 2575 | ||
| 2519 | 2576 | /// `job_report`: the sandbox telling how the job is going. | |
| ⋯ | |||
| 2839 | 2896 | } | |
| 2840 | 2897 | ||
| 2841 | 2898 | #[cfg(test)] | |
| 2899 | + | mod token_rotation { | |
| 2900 | + | use g1t_secrets::sha256_hex; | |
| 2901 | + | use rusqlite::{Connection, OptionalExtension, params}; | |
| 2902 | + | ||
| 2903 | + | use super::{TOKEN_ROTATE, token_opens}; | |
| 2904 | + | ||
| 2905 | + | fn database() -> Connection { | |
| 2906 | + | let db = Connection::open_in_memory().unwrap(); | |
| 2907 | + | for name in ["0001_init.sql", "0020_job_token_rotation.sql"] { | |
| 2908 | + | let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR")); | |
| 2909 | + | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 2910 | + | } | |
| 2911 | + | db.execute( | |
| 2912 | + | "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)", | |
| 2913 | + | [sha256_hex("first")], | |
| 2914 | + | ) | |
| 2915 | + | .unwrap(); | |
| 2916 | + | db | |
| 2917 | + | } | |
| 2918 | + | ||
| 2919 | + | fn rotate(db: &Connection, next: &str, shown: &str) -> bool { | |
| 2920 | + | db.query_row(TOKEN_ROTATE, params![sha256_hex(next), "job_1", sha256_hex(shown)], |row| row.get::<_, String>(0)) | |
| 2921 | + | .optional() | |
| 2922 | + | .unwrap() | |
| 2923 | + | .is_some() | |
| 2924 | + | } | |
| 2925 | + | ||
| 2926 | + | /// The job's current token's hash, and the one it started with. | |
| 2927 | + | fn hashes(db: &Connection) -> (Option<String>, Option<String>) { | |
| 2928 | + | db.query_row("SELECT token_hash, first_token_hash FROM jobs WHERE id = 'job_1'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap() | |
| 2929 | + | } | |
| 2930 | + | ||
| 2931 | + | #[test] | |
| 2932 | + | fn a_job_token_fetches_its_spec_once() { | |
| 2933 | + | let db = database(); | |
| 2934 | + | assert!(rotate(&db, "second", "first")); | |
| 2935 | + | assert_eq!(hashes(&db), (Some(sha256_hex("second")), Some(sha256_hex("first")))); | |
| 2936 | + | // The first token, shown again (a copy left on the computer, or a | |
| 2937 | + | // second fetch racing the first), swaps nothing and is refused. | |
| 2938 | + | assert!(!rotate(&db, "third", "first")); | |
| 2939 | + | assert_eq!(hashes(&db).0, Some(sha256_hex("second"))); | |
| 2940 | + | // A job that ended has no token to swap. | |
| 2941 | + | db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL, first_token_hash = NULL", []).unwrap(); | |
| 2942 | + | assert!(!rotate(&db, "fourth", "second")); | |
| 2943 | + | assert_eq!(hashes(&db), (None, None)); | |
| 2944 | + | } | |
| 2945 | + | ||
| 2946 | + | #[test] | |
| 2947 | + | fn the_worker_finds_brokered_secrets_with_the_token_the_job_started_with() { | |
| 2948 | + | let db = database(); | |
| 2949 | + | let opens = |db: &Connection, token: &str, started: bool| { | |
| 2950 | + | let (current, first) = hashes(db); | |
| 2951 | + | token_opens(current.as_deref(), first.as_deref(), token, started) | |
| 2952 | + | }; | |
| 2953 | + | // Before any swap, the job's one token opens everything. | |
| 2954 | + | assert!(opens(&db, "first", false) && opens(&db, "first", true)); | |
| 2955 | + | assert!(rotate(&db, "second", "first")); | |
| 2956 | + | // The harness's new token opens everything. | |
| 2957 | + | assert!(opens(&db, "second", false) && opens(&db, "second", true)); | |
| 2958 | + | // The token it started with, which the runner Worker holds, still | |
| 2959 | + | // finds the job's brokered secrets, and opens nothing else. | |
| 2960 | + | assert!(opens(&db, "first", true)); | |
| 2961 | + | assert!(!opens(&db, "first", false)); | |
| 2962 | + | assert!(!opens(&db, "other", true)); | |
| 2963 | + | // Another swap keeps the token it started with, not the one between. | |
| 2964 | + | assert!(rotate(&db, "third", "second")); | |
| 2965 | + | assert!(opens(&db, "first", true)); | |
| 2966 | + | assert!(!opens(&db, "second", true)); | |
| 2967 | + | } | |
| 2968 | + | ||
| 2969 | + | #[test] | |
| 2970 | + | fn the_runtime_token_is_signed_for_the_token_that_replaces_the_first() { | |
| 2971 | + | let now = 1_800_000_000; | |
| 2972 | + | let token = crate::runtime::runtime_token("job_1", "run_1", &sha256_hex("second"), now, 600); | |
| 2973 | + | assert!(crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("second"), now)); | |
| 2974 | + | assert!(!crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("first"), now)); | |
| 2975 | + | } | |
| 2976 | + | } | |
| 2977 | + | ||
| 2978 | + | #[cfg(test)] | |
| 2842 | 2979 | mod stopping { | |
| 2843 | 2980 | use super::stops_runs; | |
| 2844 | 2981 | use g1t_contracts::events::Event; | |
| 1212 | 1212 | let claimed = self | |
| 1213 | 1213 | .db | |
| 1214 | 1214 | .prepare( | |
| 1215 | − | "UPDATE jobs SET status = 'in_progress', token_hash = ?, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ? | |
| 1215 | + | "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ? | |
| 1216 | 1216 | WHERE id = ? AND status = 'queued' RETURNING id", | |
| 1217 | 1217 | ) | |
| 1218 | 1218 | .bind(&[ |
Comments
Sign in to comment.