Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

A computer whose runner is registered again takes its own runner's name back instead of being refused. When the desktop app's sandbox was made again (a new VM, its registration gone), its runners registered under the names the old sandbox's still held on g1t, were refused with a 409 asking for --replace, and tried again forever. A runner now says which computer it is when it registers: a fingerprint g1t-runner works out from the operating system's machine id (Windows' MachineGuid, macOS's platform UUID, Linux's /etc/machine-id), hashed so the id never leaves the computer, or from --machine / G1T_RUNNER_MACHINE; a container sends none, since its machine id is its image's, and g1t-runner sandbox passes the computer's to the runner in the VM. The actions service keeps it hashed again with the workspace (migration 0022 adds runners.machine_hash), and a registration of a name already taken replaces that runner when it was registered from the same computer, or, for a runner from before fingerprints, when the same person made both tokens and the old runner has been offline for 90 seconds; a runner of the same name from another computer is still refused without --replace, and the refusal says so. The self-hosted runners guide covers registering again and --machine.

syntaqxcommitted Parentf82977fBrowse files
9 files+185−90/9 viewed
+2−1
55 //!
66 //! | Route | Body |
77 //! | --- | --- |
8−//! | `POST /runners/register` | `token`, `name`, `labels`, `os`, `arch`, `version`, `ephemeral`, `group`, `replace`, `isolation` |
8+//! | `POST /runners/register` | `token`, `name`, `labels`, `os`, `arch`, `version`, `ephemeral`, `group`, `replace`, `isolation`, `machine` |
99 //! | `POST /runners/{id}/poll` | `version`, `running`, `wait_ms` |
1010 //! | `POST /runners/{id}/finished` | `id`, `exit_code`, `reason` |
1111 //! | `POST /runners/{id}/remove` | nothing |
5757 "group": body["group"].as_str(),
5858 "replace": body["replace"].as_bool().unwrap_or(false),
5959 "isolation": body["isolation"].as_str(),
60+ "machine": body["machine"].as_str(),
6061 });
6162 return Ok(Some(answer(services, "runner_register", &args).await?));
6263 }
+23−2
551551 | `--job-docker-image` | The image a job's own Docker Engine runs. `docker:28-dind` if left out. |
552552 | `--work-dir` | Where jobs run with `--no-docker` keep their files. `~/.g1t-runner/work` if left out. |
553553 | `--dir` | Where the runner keeps its configuration and credential. `~/.g1t-runner`, or `G1T_RUNNER_DIR`, if left out. Every command takes it. |
554−| `--replace` | Take the place of a runner with the same name. |
554+| `--replace` | Take the place of a runner with the same name, registered from any computer. A runner registering again from the computer it registered from takes its name back without it ([registering again](#registering-again)). |
555+| `--machine` | Which computer this is, as 32 to 128 hex digits, for a runner that can't tell ([registering again](#registering-again)). Worked out from the operating system if left out. |
555556 | `--no-auto-update` | Never update itself. |
556557
558+### Registering again
559+
560+A runner says which computer it is when it registers: a fingerprint made
561+from the operating system's machine id (Windows' `MachineGuid`, macOS's
562+platform UUID, Linux's `/etc/machine-id`), hashed so the id itself never
563+leaves the computer, and kept by g1t hashed again with your workspace. When
564+a computer registers a runner under a name one of its own runners already
565+has, because its sandbox was made again or its `--dir` was lost, the new
566+registration takes the old one's place, with no `--replace`. A runner of
567+the same name from another computer is left alone.
568+
569+A runner in a container can't tell which computer it is on, since its
570+machine id is its image's, so it sends no fingerprint unless given one
571+with `--machine` or `G1T_RUNNER_MACHINE`. `g1t-runner sandbox` and the
572+desktop app pass the computer's to the runner in the sandbox.
573+
574+A runner registered before runners sent a fingerprint is taken over when
575+the person who made the new registration token made its token too, and it
576+has been offline for more than 90 seconds.
577+
557578 To run several runners on one machine, give each its own `--dir` and
558579 `--name`.
559580
793814 | The job says *Waiting for a self-hosted runner with labels …* | No online runner has every one of those labels in a group that allows the repository. Compare the labels on the **Runners** page, check the runner's group, or start the runner. |
794815 | *Pull requests from forks do not run on self-hosted runners here.* | The run is from a fork. Allow it under **Where work runs**, if you trust everyone who can open a pull request. |
795816 | `register` says the token is not valid | It expired after an hour, or was mistyped. Make a new one. |
796−| `register` says a runner with that name is registered | Choose another `--name`, or add `--replace`. |
817+| `register` says a runner with that name is registered | Another computer's runner has that name, or one registered before the computer was known. Choose another `--name`, or add `--replace`. |
797818 | `run` says *g1t no longer knows this runner* | It was removed, or its credential was. Register it again. |
798819 | *this runner is not sandboxed from its machine, so it will not take work* | The runner's container can reach the machine: it lists why. Remove the machine's Docker socket and `--privileged`, and run it with `--runtime=sysbox-runc`. See [run it as a container](#run-it-as-a-container). |
799820 | *the runner's own Docker Engine could not start* | The container is not running under Sysbox. Add `--runtime=sysbox-runc`, and check that Sysbox is installed (`docker info` lists `sysbox-runc` under runtimes). |
+8−0
525525 /// runners leave it out.
526526 #[serde(default)]
527527 pub isolation: Option<String>,
528+ /// Which computer it is: a fingerprint the runner derives from its
529+ /// operating system's machine id, never the id itself. A runner of the
530+ /// same name registered from the same computer is taken over without
531+ /// `replace`, so a computer whose sandbox was made again registers
532+ /// again by itself. Older runners, and those in a container that can't
533+ /// tell, leave it out.
534+ #[serde(default)]
535+ pub machine: Option<String>,
528536 }
529537
530538 /// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
+13−0
7979 pub labels: Vec<String>,
8080 #[serde(default)]
8181 pub group: Option<String>,
82+ /// The computer's fingerprint (`machine_fingerprint`), which the
83+ /// runner in the VM can't work out for itself.
84+ #[serde(default)]
85+ pub machine: Option<String>,
8286 /// The runner's container's share of the VM, and so its jobs'.
8387 #[serde(default)]
8488 pub cpus: u32,
237241 if let Some(group) = &settings.group {
238242 lines.push(format!("G1T_RUNNER_GROUP={group}"));
239243 }
244+ if let Some(machine) = &settings.machine {
245+ lines.push(format!("G1T_RUNNER_MACHINE={machine}"));
246+ }
240247 if lines.iter().any(|l| l.contains('\n') || l.contains('\r')) {
241248 bail!("a registration setting has a line break in it");
242249 }
388395 if given.group.is_some() {
389396 settings.group = given.group;
390397 }
398+ if given.machine.is_some() {
399+ settings.machine = given.machine;
400+ }
391401 if given.cpus > 0 {
392402 settings.cpus = given.cpus;
393403 }
916926 };
917927 let env = register_env(&settings).unwrap();
918928 assert_eq!(env, "G1T_RUNNER_URL=https://g1t.sh\nG1T_RUNNER_TOKEN=g1trt_x\nG1T_RUNNER_NAME=box-linux\nG1T_RUNNER_LABELS=gpu\n");
929+ // The computer's fingerprint, which the runner in the VM can't work out.
930+ settings.machine = Some("ab".repeat(32));
931+ assert!(register_env(&settings).unwrap().ends_with(&format!("G1T_RUNNER_MACHINE={}\n", "ab".repeat(32))));
919932 settings.name = Some("a\nG1T_RUNNER_URL=https://evil".into());
920933 assert!(register_env(&settings).is_err());
921934 assert!(register_env(&Settings::default()).is_err());
+2−0
970970 name: Some(format!("{}-linux", base_name(saved))),
971971 labels: saved.labels.clone(),
972972 group: saved.group.clone(),
973+ machine: super::machine_fingerprint(),
973974 cpus: saved.resources.cpus,
974975 memory_mib: saved.resources.memory_mib,
975976 runner_image,
11611162 name: Some(format!("{}-linux", base_name(saved))),
11621163 labels: saved.labels.clone(),
11631164 group: saved.group.clone(),
1165+ machine: super::machine_fingerprint(),
11641166 ..Settings::default()
11651167 })?;
11661168 if was_running {
+1−0
108108 name: given.name.or(merged.name),
109109 labels: if given.labels.is_empty() { merged.labels } else { given.labels },
110110 group: given.group.or(merged.group),
111+ machine: given.machine.or(merged.machine),
111112 cpus: if given.cpus > 0 { given.cpus } else { merged.cpus },
112113 memory_mib: if given.memory_mib > 0 { given.memory_mib } else { merged.memory_mib },
113114 runner_image: if given.runner_image.is_empty() { merged.runner_image } else { given.runner_image },
+45−2
148148 }
149149
150150 /// The options that take a value.
151−const VALUE_OPTIONS: [&str; 13] =
152− ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"];
151+const VALUE_OPTIONS: [&str; 14] =
152+ ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "harness", "memory", "cpus", "pids-limit", "job-docker-image", "machine"];
153153
154154 const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
155155
259259 .unwrap_or_else(|| "runner".to_owned())
260260 }
261261
262+/// Which computer this is, for g1t to know it when it registers again (its
263+/// sandbox made again, its state lost) and let it take its runners' names
264+/// back: a hash of the operating system's machine id, never the id itself.
265+/// None in a container, whose machine id is its image's and so shared by
266+/// every container made from it (the sandbox's launcher passes the
267+/// computer's as `G1T_RUNNER_MACHINE`), and where the OS has none.
268+pub fn machine_fingerprint() -> Option<String> {
269+ use sha2::{Digest, Sha256};
270+ let id = os_machine_id()?;
271+ Some(hex::encode(Sha256::digest(format!("g1t-runner machine\n{id}"))))
272+}
273+
274+fn os_machine_id() -> Option<String> {
275+ let id = if cfg!(windows) {
276+ // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, set when Windows is installed.
277+ let out = hidden(std::process::Command::new("reg").args(["query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"])).output().ok()?;
278+ String::from_utf8_lossy(&out.stdout).lines().find(|l| l.contains("MachineGuid"))?.split_whitespace().last()?.to_owned()
279+ } else if cfg!(target_os = "macos") {
280+ let out = std::process::Command::new("ioreg").args(["-rd1", "-c", "IOPlatformExpertDevice"]).output().ok()?;
281+ let text = String::from_utf8_lossy(&out.stdout).into_owned();
282+ text.lines().find(|l| l.contains("\"IOPlatformUUID\""))?.rsplit('"').nth(1)?.to_owned()
283+ } else {
284+ if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
285+ return None;
286+ }
287+ ["/etc/machine-id", "/var/lib/dbus/machine-id"].iter().find_map(|path| std::fs::read_to_string(path).ok())?
288+ };
289+ let id = id.trim().to_ascii_lowercase();
290+ (id.len() >= 8 && id.chars().any(|c| c != '0' && c != '-')).then_some(id)
291+}
292+
293+/// No console window for a helper the runner runs on Windows.
294+fn hidden(command: &mut std::process::Command) -> &mut std::process::Command {
295+ #[cfg(windows)]
296+ {
297+ use std::os::windows::process::CommandExt;
298+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;
299+ command.creation_flags(CREATE_NO_WINDOW);
300+ }
301+ command
302+}
303+
262304 /// A runner's name from a machine's: what g1t accepts.
263305 fn runner_name(given: &str) -> String {
264306 let name: String = given
325367 "group": options.value("group"),
326368 "replace": options.flag("replace"),
327369 "isolation": isolation.as_str(),
370+ "machine": options.value("machine").map(str::to_owned).or_else(machine_fingerprint),
328371 }),
329372 )?;
330373 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
+5−0
1+-- Which computer a self-hosted runner registered from, as a hash of the
2+-- fingerprint it sent: a runner of the same name registered again from the
3+-- same computer takes the old one's place without --replace. Null for
4+-- runners that registered before runners said so.
5+ALTER TABLE runners ADD COLUMN machine_hash TEXT;
+86−4
8282 /// for a runner that never said.
8383 #[serde(default)]
8484 pub isolation: Option<String>,
85+ /// Which computer it registered from (migration 0022): a hash of the
86+ /// fingerprint it sent, with its workspace; none for a runner that
87+ /// never said.
88+ #[serde(default)]
89+ pub machine_hash: Option<String>,
8590 }
8691
8792 impl RunnerRow {
194199 sha256_hex(secret)
195200 }
196201
202+/// What a runner's machine fingerprint is kept as: hashed with its
203+/// workspace, so the same computer is not recognisable across workspaces.
204+/// A fingerprint is 32 to 128 hex digits; anything else counts as none.
205+fn machine_hash(workspace: &str, machine: Option<&str>) -> Option<String> {
206+ let machine = machine.map(str::trim).filter(|m| (32..=128).contains(&m.len()) && m.chars().all(|c| c.is_ascii_hexdigit()))?;
207+ Some(hash(&format!("{workspace}\n{}", machine.to_ascii_lowercase())))
208+}
209+
210+/// Whether a registration takes the place of `existing`, a runner of the
211+/// same name: when asked to (`--replace`); when it is the same computer
212+/// registering again (its sandbox made again, its state lost); or, for a
213+/// runner from before runners said which computer they are, when the same
214+/// person registered it and it is no longer heard from.
215+fn takes_over(existing: &RunnerRow, replace: bool, machine_hash: Option<&str>, created_by: Option<&str>, now: u64) -> bool {
216+ let same_machine = machine_hash.is_some() && existing.machine_hash.as_deref() == machine_hash;
217+ let left_behind = existing.machine_hash.is_none() && created_by.is_some() && existing.created_by.as_deref() == created_by && !existing.online(now);
218+ replace || same_machine || left_behind
219+}
220+
197221 /// The actor of a runner's own entries in the audit log.
198222 fn runner_actor(runner: &RunnerRow) -> AuditActor {
199223 AuditActor {
10101034 .bind(&[workspace.as_str().into(), registration.repo_id.clone().unwrap_or_default().into(), name.as_str().into()])?
10111035 .first::<RunnerRow>(None)
10121036 .await?;
1037+ let machine_hash = machine_hash(&workspace, a.machine.as_deref());
10131038 if let Some(existing) = &existing {
1014− if !a.replace {
1039+ if !takes_over(existing, a.replace, machine_hash.as_deref(), registration.created_by.as_deref(), at) {
1040+ let elsewhere = if existing.machine_hash.is_some() && machine_hash.is_some() { " from another computer" } else { "" };
10151041 return Ok(fail(
10161042 FailureCode::Conflict,
1017− format!("A runner called {name} is already registered here. Register with --replace to take its place, or choose another --name."),
1043+ format!("A runner called {name} is already registered here{elsewhere}. Register with --replace to take its place, or choose another --name."),
10181044 ));
10191045 }
10201046 self.forget_runner(existing, &format!("The runner {name} was replaced by a new registration.")).await?;
10291055 self.db
10301056 .prepare(
10311057 "INSERT INTO runners (id, workspace, repo_id, repo, group_id, name, labels, os, arch, version, ephemeral, credential_hash,
1032− rotated_at, last_seen_at, created_at, created_by, isolation)
1033− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1058+ rotated_at, last_seen_at, created_at, created_by, isolation, machine_hash)
1059+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
10341060 )
10351061 .bind(&[
10361062 id.as_str().into(),
10511077 // The person who made the token: a desktop's runner is theirs.
10521078 optional(registration.created_by.as_deref()),
10531079 optional(isolation),
1080+ optional(machine_hash.as_deref()),
10541081 ])?
10551082 .run()
10561083 .await?;
19852012 created_at: String::new(),
19862013 created_by: None,
19872014 isolation: isolation.map(str::to_owned),
2015+ machine_hash: None,
19882016 };
19892017 let wanted = Wanted::of(&["self-hosted".to_owned(), "linux".to_owned()], None);
19902018 let takes = |row: &RunnerRow| takes_public_job(row, &[], "rep_1", "acme/web", &wanted, at);
20212049 created_at: String::new(),
20222050 created_by: None,
20232051 isolation: None,
2052+ machine_hash: None,
20242053 };
20252054 assert!(row.online(1_000_000_000 + 60_000));
20262055 assert!(!row.online(1_000_000_000 + 120_000));
20272056 assert_eq!(row.workspace, "acme");
20282057 }
2058+
2059+ #[test]
2060+ fn a_machine_fingerprint_is_kept_per_workspace_and_only_when_it_is_one() {
2061+ let print = "ab".repeat(32);
2062+ assert_eq!(machine_hash("acme", Some(&print)), machine_hash("acme", Some(&print.to_ascii_uppercase())));
2063+ assert_ne!(machine_hash("acme", Some(&print)), machine_hash("other", Some(&print)));
2064+ assert_eq!(machine_hash("acme", Some("SYNTAQX")), None);
2065+ assert_eq!(machine_hash("acme", Some("abc")), None);
2066+ assert_eq!(machine_hash("acme", None), None);
2067+ }
2068+
2069+ #[test]
2070+ fn the_same_computer_takes_its_runners_name_back() {
2071+ let at = 1_000_000_000;
2072+ let ours = machine_hash("acme", Some(&"ab".repeat(32)));
2073+ let theirs = machine_hash("acme", Some(&"cd".repeat(32)));
2074+ let row = |machine_hash: Option<String>, created_by: Option<&str>, seen: u64| RunnerRow {
2075+ id: "rnr_1".into(),
2076+ workspace: "acme".into(),
2077+ repo_id: None,
2078+ repo: None,
2079+ group_id: None,
2080+ name: "box-linux".into(),
2081+ labels: "[]".into(),
2082+ os: "linux".into(),
2083+ arch: "x64".into(),
2084+ version: String::new(),
2085+ ephemeral: 0,
2086+ credential_hash: String::new(),
2087+ previous_hash: None,
2088+ rotated_at: String::new(),
2089+ work_id: None,
2090+ work_kind: None,
2091+ spent: 0,
2092+ last_seen_at: Some(rfc3339(seen)),
2093+ created_at: String::new(),
2094+ created_by: created_by.map(str::to_owned),
2095+ isolation: None,
2096+ machine_hash,
2097+ };
2098+ // The same computer, even while the old one still looks online.
2099+ assert!(takes_over(&row(ours.clone(), Some("u1"), at), false, ours.as_deref(), Some("u1"), at));
2100+ // Another computer of the same name: only when asked to.
2101+ assert!(!takes_over(&row(theirs.clone(), Some("u1"), 0), false, ours.as_deref(), Some("u1"), at));
2102+ assert!(takes_over(&row(theirs, Some("u1"), at), true, ours.as_deref(), Some("u1"), at));
2103+ // One from before fingerprints: the same person's, gone quiet.
2104+ assert!(takes_over(&row(None, Some("u1"), 0), false, ours.as_deref(), Some("u1"), at));
2105+ assert!(!takes_over(&row(None, Some("u1"), at), false, ours.as_deref(), Some("u1"), at));
2106+ assert!(!takes_over(&row(None, Some("u2"), 0), false, ours.as_deref(), Some("u1"), at));
2107+ assert!(!takes_over(&row(None, None, 0), false, ours.as_deref(), None, at));
2108+ // A runner without a fingerprint never matches one without.
2109+ assert!(!takes_over(&row(None, Some("u1"), at), false, None, Some("u1"), at));
2110+ }
20292111 }