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

Brokered secrets work on self-hosted runners whose jobs run on Linux or macOS, and the value still never reaches the runner's computer. The job keeps its stand-in; a harness that can broker says so when it fetches its spec (`broker`), and the spec then lists each stand-in's hosts (`brokered`), never a value. The harness runs a proxy for the job on 127.0.0.1 and gives the steps HTTPS_PROXY, NODE_USE_ENV_PROXY and the trusted-certificate variables g1t's own sandboxes set (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, PIP_CERT, GIT_SSL_CAINFO, CARGO_HTTP_CAINFO), each pointing at what it pointed at before with the job's certificate added. HTTPS to any other host is a plain tunnel. HTTPS to a brokered host is opened with a certificate from an authority made for that job alone, name-constrained to the brokered hosts and with its key only in memory; each request on it that carries one of the job's stand-ins goes to POST /actions/jobs/{job}/broker with the job's current token in Authorization, and a request without one goes straight to the host on a connection of its own.

The API passes the broker request, streamed, to the runner Worker (apps/api broker.rs), which asks the actions service for the job's stand-ins with the new relayed_secrets (current token only, self-hosted jobs only), fills them in with broker.ts's fill, sends the request without following redirects, and answers with the upstream status and headers in x-g1t-broker-response and the decoded body, the values swapped back for their stand-ins in the headers and in any text body whatever its size (a streaming byte scrub that finds a value split across chunks). It is not a proxy for anything else: a request that carries none of the job's stand-ins for its host, or is not HTTPS, is refused, and the job's log says so once per secret and host. Request bodies are 32 MB at most, answers 1 GB, and a request with its answer 10 minutes. Older runners, Windows jobs (Windows Sandbox and host mode, whose tools trust the system store rather than these variables), and a computer that sets its own HTTPS_PROXY keep today's behaviour: the stand-in, and a warning in the log. A job that brokers is told instead which hosts each brokered secret's requests go through g1t for. Containers a step starts (Docker actions, services) do not get the proxy. The secrets, actions, guardrails and self-hosted runners guides say how it works, its limits and what it does not cover, and the brokered_hosts description in the API reference names self-hosted runners.

syntaqxcommitted Parent4d0236bBrowse files
20 files+1499−234 files not counted0/20 viewed
+73−0
598598 ]
599599
600600 [[package]]
601+name = "deranged"
602+version = "0.5.8"
603+source = "registry+https://github.com/rust-lang/crates.io-index"
604+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
605+
606+[[package]]
601607 name = "des"
602608 version = "0.9.0"
603609 source = "registry+https://github.com/rust-lang/crates.io-index"
11321138 "g1t-scan",
11331139 "hex",
11341140 "libc",
1141+ "rcgen",
11351142 "regex",
1143+ "rustls",
11361144 "serde",
11371145 "serde_json",
11381146 "serde_yaml",
11391147 "sha2 0.10.9",
1148+ "time",
11401149 "toml",
11411150 "ureq",
1151+ "webpki-roots 0.26.11",
11421152 ]
11431153
11441154 [[package]]
19361946 ]
19371947
19381948 [[package]]
1949+name = "num-conv"
1950+version = "0.2.2"
1951+source = "registry+https://github.com/rust-lang/crates.io-index"
1952+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1953+
1954+[[package]]
19391955 name = "num-integer"
19401956 version = "0.1.47"
19411957 source = "registry+https://github.com/rust-lang/crates.io-index"
20912107 ]
20922108
20932109 [[package]]
2110+name = "pem"
2111+version = "3.0.6"
2112+source = "registry+https://github.com/rust-lang/crates.io-index"
2113+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
2114+dependencies = [
2115+ "base64 0.22.1",
2116+ "serde_core",
2117+]
2118+
2119+[[package]]
20942120 name = "pem-rfc7468"
20952121 version = "1.0.0"
20962122 source = "registry+https://github.com/rust-lang/crates.io-index"
22312257 ]
22322258
22332259 [[package]]
2260+name = "powerfmt"
2261+version = "0.2.1"
2262+source = "registry+https://github.com/rust-lang/crates.io-index"
2263+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
2264+
2265+[[package]]
22342266 name = "primefield"
22352267 version = "0.14.0"
22362268 source = "registry+https://github.com/rust-lang/crates.io-index"
23742406 ]
23752407
23762408 [[package]]
2409+name = "rcgen"
2410+version = "0.13.2"
2411+source = "registry+https://github.com/rust-lang/crates.io-index"
2412+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
2413+dependencies = [
2414+ "pem",
2415+ "ring",
2416+ "rustls-pki-types",
2417+ "time",
2418+ "yasna",
2419+]
2420+
2421+[[package]]
23772422 name = "redox_syscall"
23782423 version = "0.5.18"
23792424 source = "registry+https://github.com/rust-lang/crates.io-index"
32763321 ]
32773322
32783323 [[package]]
3324+name = "time"
3325+version = "0.3.55"
3326+source = "registry+https://github.com/rust-lang/crates.io-index"
3327+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3328+dependencies = [
3329+ "deranged",
3330+ "num-conv",
3331+ "powerfmt",
3332+ "serde_core",
3333+ "time-core",
3334+]
3335+
3336+[[package]]
3337+name = "time-core"
3338+version = "0.1.9"
3339+source = "registry+https://github.com/rust-lang/crates.io-index"
3340+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3341+
3342+[[package]]
32793343 name = "tinystr"
32803344 version = "0.8.4"
32813345 source = "registry+https://github.com/rust-lang/crates.io-index"
40444108 ]
40454109
40464110 [[package]]
4111+name = "yasna"
4112+version = "0.5.2"
4113+source = "registry+https://github.com/rust-lang/crates.io-index"
4114+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
4115+dependencies = [
4116+ "time",
4117+]
4118+
4119+[[package]]
40474120 name = "yoke"
40484121 version = "0.8.3"
40494122 source = "registry+https://github.com/rust-lang/crates.io-index"
+57−0
1+//! `POST /actions/jobs/{job}/broker`: a request a self-hosted runner's job
2+//! made to one of its brokered secrets' hosts, which the job's harness
3+//! passes to g1t so the secret's value never reaches the runner's
4+//! computer (services/actions broker.rs, crates/runner actions/broker.rs).
5+//!
6+//! The job's current token comes in `Authorization: Bearer`, never in the
7+//! URL, and the request to make in `x-g1t-broker-request` (base64 of
8+//! `{ method, url, headers }`), with its body as this request's. The
9+//! runner Worker does the rest (services/runner broker-relay.ts): it
10+//! fills in the stand-ins, sends the request without following redirects,
11+//! and answers with what came back, scrubbed of the values, its status and
12+//! headers in `x-g1t-broker-response`. Bodies stream through here as they
13+//! are; the Worker holds the limits.
14+
15+use worker::wasm_bindgen::JsValue;
16+use worker::{Method, Request, RequestInit, Response, Result};
17+
18+use crate::operations::Services;
19+
20+/// The headers passed on to the runner Worker; nothing else of the
21+/// request is. Its body's length the Worker counts as it reads.
22+const PASSED: &[&str] = &["authorization", "x-g1t-broker-request"];
23+
24+/// The job a broker request is for, from its path, or None when the path
25+/// is not one.
26+pub fn job_of(path: &str) -> Option<&str> {
27+ let job = path.strip_prefix("/actions/jobs/")?.strip_suffix("/broker")?;
28+ (!job.is_empty() && !job.contains('/')).then_some(job)
29+}
30+
31+/// Passes a broker request to the runner Worker, and its answer back.
32+pub async fn relay(request: Request, services: &Services, job: &str) -> Result<Response> {
33+ let headers = worker::Headers::new();
34+ headers.set("x-g1t-broker-job", job)?;
35+ for name in PASSED {
36+ if let Some(value) = request.headers().get(name)? {
37+ headers.set(name, &value)?;
38+ }
39+ }
40+ let mut init = RequestInit::new();
41+ init.with_method(Method::Post).with_headers(headers).with_body(request.inner().body().map(JsValue::from));
42+ services.runner.fetch_request(Request::new_with_init("https://runner/rpc/broker", &init)?).await
43+}
44+
45+#[cfg(test)]
46+mod tests {
47+ use super::job_of;
48+
49+ #[test]
50+ fn only_a_job_s_own_broker_path_is_one() {
51+ assert_eq!(job_of("/actions/jobs/job_1/broker"), Some("job_1"));
52+ assert_eq!(job_of("/actions/jobs//broker"), None);
53+ assert_eq!(job_of("/actions/jobs/job_1/spec"), None);
54+ assert_eq!(job_of("/actions/jobs/a/b/broker"), None);
55+ assert_eq!(job_of("/repos/a/b/actions/jobs/job_1/broker"), None);
56+ }
57+}
+14−1
1212 mod audit;
1313 mod billing;
1414 mod blobs;
15+mod broker;
1516 mod checks;
1617 mod customize;
1718 mod deployments;
619620 let rest = rest.to_owned();
620621 return blobs::for_job(request, env, &services, method, &rest).await;
621622 }
623+ // A self-hosted job's request to a brokered secret's host, passed to
624+ // g1t to be filled in (broker.rs). The job's token is the credential.
625+ if !on_mcp
626+ && method == "POST"
627+ && let Some(job) = broker::job_of(&path)
628+ {
629+ let job = job.to_owned();
630+ return broker::relay(request, &services, &job).await;
631+ }
622632
623633 // A self-hosted runner, with a registration token or its own
624634 // credential, neither of which is a g1t access token.
772782 // `rotate`: a harness that takes a new token with the spec and
773783 // lets go of the one it asked with (plan.rs `job_spec`).
774784 let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true);
785+ // `broker`: a self-hosted harness that passes its job's
786+ // requests to brokered hosts through g1t (broker.rs).
787+ let broker = method == "job_spec" && body["broker"].as_bool() == Some(true);
775788 let answered: Outcome<Value> = g1t_kit::call(
776789 &services.actions,
777790 method,
778− &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }),
791+ &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate, "broker": broker }),
779792 )
780793 .await?;
781794 return match answered {
+1−1
16901690 "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, whether workflows (`secrets.NAME`), deployments, or both read it, and for a brokered secret the hosts g1t fills it in for (`brokered_hosts`). Values are never returned. A repository's need the Admin role on it; a workspace's, a member."
16911691 }
16921692 Op::SetActionsSecret => {
1693− "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
1693+ "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it; a self-hosted runner whose jobs run on Linux or macOS passes the job's requests to those hosts through g1t, so the value never reaches its computer either. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
16941694 }
16951695 Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.",
16961696 Op::ListActionsVariables => {
+5−3
630630 The value never enters the job, so neither its steps nor a compromised
631631 dependency can read it. A stand-in sent to any other host goes as it is,
632632 and the log says so once. This works on g1t's machines, whether the
633−project's network is restricted or open; a job on a self-hosted runner
634−gets the stand-in too, never the value, and its log warns that it will
635−not work there yet. A secret the job must read itself (a database
633+project's network is restricted or open, and on self-hosted runners whose
634+jobs run on Linux or macOS, which pass the job's requests to those hosts
635+through g1t so the value never reaches the runner's computer. A job on
636+Windows gets the stand-in too, never the value, and its log warns that it
637+will not work there yet. A secret the job must read itself (a database
636638 password over raw TCP, a signing key, a key to files it decrypts) cannot
637639 be brokered. See
638640 [brokered secrets](/guides/secrets-and-variables/#brokered-secrets).
+6−2
192192 given a brokered secret, still sends its HTTP and HTTPS through the
193193 Worker, which reaches every host for it, and trusts the certificate as a
194194 restricted sandbox does; its other protocols go straight out, as before.
195−Self-hosted runners' traffic does not pass through g1t, so brokered
196−secrets stay stand-ins there.
195+Self-hosted runners' traffic does not pass through g1t, so a runner whose
196+jobs run on Linux or macOS sends the requests that carry a stand-in to g1t
197+itself, through a proxy it runs for the job, and g1t fills them in the same
198+way; see
199+[on a self-hosted runner](/guides/secrets-and-variables/#on-a-self-hosted-runner).
200+On Windows, brokered secrets stay stand-ins.
197201
198202 ## Commands
199203
+66−3
157157 brokered secret sends its HTTP and HTTPS through g1t either way, and
158158 trusts the certificate HTTPS is re-signed with, as a restricted one does.
159159 Other protocols are not affected.
160−- **Self-hosted runners: not yet.** Their traffic never passes through
161− g1t, so a job there gets the stand-in too, never the value, and its log
162− warns that requests using it will not be signed in. Keep a separate,
160+- **Self-hosted runners on Linux and macOS**, in Docker, in the desktop
161+ app's sandbox, or directly on the computer: the job's requests to the
162+ secret's hosts go through g1t, which fills the value in, so it never
163+ reaches your computer. See
164+ [on a self-hosted runner](#on-a-self-hosted-runner).
165+- **Self-hosted runners whose jobs run on Windows** (Windows Sandbox, or
166+ directly on Windows): not yet. A job there gets the stand-in, never the
167+ value, and its log warns that requests using it will not be signed in.
168+ So does a job on a runner from before brokering. Keep a separate,
163169 unbrokered secret for jobs that must run there.
164170 - **Deployments** never read a brokered secret: a running app needs the
165171 value itself. A brokered secret is available to workflows only.
168174 The value is masked in the job's log as any secret is, should it ever
169175 appear there, and so is the stand-in.
170176
177+### On a self-hosted runner
178+
179+A self-hosted runner's traffic does not leave through g1t, and g1t never
180+sends a brokered value to a computer it does not run. So the runner
181+brings the requests that need the value to g1t instead:
182+
183+1. The job gets the stand-in, as on g1t's machines, and the runner is
184+ told which hosts each stand-in is for. It is never told a value.
185+2. The job's steps get `HTTPS_PROXY` pointing at a proxy the runner runs
186+ for that job on `127.0.0.1`. HTTPS to any host the job's brokered
187+ secrets are not for passes through it as a plain tunnel, which the
188+ runner never sees inside.
189+3. HTTPS to one of those hosts is opened with a certificate the runner
190+ makes for that host. It comes from a certificate authority made for
191+ that job alone, which can sign for the brokered hosts and no others,
192+ and whose key is never written to disk. The steps trust it through the
193+ same variables as on g1t's machines (`SSL_CERT_FILE`,
194+ `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`,
195+ `PIP_CERT`, `GIT_SSL_CAINFO`, `CARGO_HTTP_CAINFO`), each pointing at
196+ what it pointed at before with the job's certificate added.
197+4. A request on that connection that carries one of the job's stand-ins
198+ goes to g1t with the job's token. g1t fills the value in as described
199+ above, sends the request without following redirects, and sends back
200+ the answer, with the value swapped back for its stand-in in its headers
201+ and in any text body, whatever its size. A request without a stand-in
202+ goes straight to the host.
203+
204+g1t relays only a request that carries one of the job's own stand-ins,
205+over HTTPS, to one of that secret's hosts, and only while the job runs,
206+with the token the job holds now. Anything else is refused, and the job's
207+log says so once. The log's first lines name each brokered secret and the
208+hosts its requests go to g1t for.
209+
210+Requests to brokered hosts take the longer way, through g1t, so they are
211+a little slower, and have limits:
212+
213+| Limit | |
214+| --- | --- |
215+| A request's body | 32 MB |
216+| An answer's body | 1 GB |
217+| One request, its answer included | 10 minutes |
218+| HTTP versions | HTTP/1.1 between the job and the runner; plain HTTP is never filled in, and `HTTP_PROXY` is left as your computer has it |
219+
220+What a self-hosted runner does not cover:
221+
222+- **Jobs that run on Windows**, as above.
223+- **Containers a step starts**: a Docker action, `services:`, or
224+ `docker run` in a step. They do not get the job's proxy, so their
225+ requests carry the stand-in as it is.
226+- **A computer that sets its own `HTTPS_PROXY`.** The runner does not
227+ chain proxies: its jobs get the stand-in and the warning.
228+- **Tools that ignore `HTTPS_PROXY`** or bring their own trusted
229+ certificates send the stand-in as it is. npm and most other package
230+ managers, `curl`, `git`, Cargo, Python's `requests` and Go use it.
231+ Node.js's own `fetch` and `https` use it in the releases that read
232+ `NODE_USE_ENV_PROXY`, which the runner sets.
233+
171234 ### What brokering cannot cover
172235
173236 A secret the job itself must read cannot be brokered. Keep these as
+9−0
323323 The runner inside fetches the job, its secrets and its `G1T_TOKEN`
324324 itself, and sends its log back the same way, masked. None of it passes
325325 through a log or file of your computer's.
326+- **A [brokered secret](/guides/secrets-and-variables/#brokered-secrets)'s
327+ value never reaches your computer at all.** The job has a stand-in, and
328+ the runner passes the job's requests to the secret's hosts to g1t, which
329+ fills the value in there and swaps it back out of the answer. This
330+ works for jobs that run on Linux or macOS: in the sandbox's VM, in
331+ Docker, or directly on the computer. A job on Windows keeps the
332+ stand-in, and its log
333+ warns that requests using it will not be signed in. See
334+ [on a self-hosted runner](/guides/secrets-and-variables/#on-a-self-hosted-runner).
326335 - **Anything running as your account can look inside a running
327336 sandbox**, as you can: open the WSL distribution with `wsl -u root`,
328337 run a command in a Windows Sandbox, or read the VM's disk. So can an
+0−0

Binary or large file; its contents are not shown.

+7−0
997997 /// that predate it never ask, and keep their token.
998998 #[serde(default)]
999999 pub rotate: bool,
1000+ /// `job_spec` only: the harness on a self-hosted runner can pass the
1001+ /// job's requests to a brokered secret's hosts through g1t
1002+ /// (`POST /actions/jobs/{job}/broker`), so its spec lists each
1003+ /// stand-in's hosts, never a value. Harnesses that predate it never
1004+ /// ask, and their job's log says the stand-ins will not be filled in.
1005+ #[serde(default)]
1006+ pub broker: bool,
10001007 }
10011008
10021009 /// What the runner needs to start a job's sandbox.
+9−0
2727 # native-certs: a guarded sandbox re-signs HTTPS with a certificate the
2828 # runner adds to the system store (guard.rs), which webpki-roots never sees.
2929 ureq = { version = "2", features = ["json", "native-certs"] }
30+# Brokered secrets on a self-hosted runner (actions/broker.rs): the job's
31+# HTTPS to a brokered host is opened with a certificate made for the job,
32+# and its requests are passed to g1t, which fills in the value. With ring,
33+# which ureq's rustls already builds.
34+rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
35+rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
36+webpki-roots = "0.26"
37+# The certificates' validity, as rcgen takes it.
38+time = { version = "0.3", default-features = false, features = ["std"] }
3039
3140 [target.'cfg(unix)'.dependencies]
3241 # A step's signals back to their defaults before it runs (actions/process.rs).
+1142−0
A large diff (1,142 lines) is not shown until you ask for it.
+31−2
99 //! job's definition, its contexts and its secrets, is fetched with them.
1010
1111 mod blobs;
12+mod broker;
1213 mod containers;
1314 mod files;
1415 mod glob;
116117 pub(crate) built_actions: containers::Built,
117118 /// `RUNNER_TRACKING_ID`, which every process a step starts inherits.
118119 tracking_id: String,
120+ /// Why the job's brokered secrets will not be filled in, when its
121+ /// proxy could not start (broker.rs), for "Set up job".
122+ broker_problem: Option<String>,
119123 }
120124
121125 fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
796800 /// [`setup`] with the workspace and temporary folder given: a test's own.
797801 fn setup_in(mut spec: Value, api: Api, workspace: PathBuf, temp: PathBuf) -> Result<Job> {
798802 let masks: Vec<String> = spec["masks"].as_array().map(|m| m.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
803+ // With the token the spec came with: the one the job started with is spent.
804+ let relay = broker::Relay::new(&api);
799805 let mut log = Log::new(api, masks);
800806 // Variables GitHub sets that a g1t from before them does not send.
801807 default_variables(&mut spec);
810816 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
811817 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
812818
813− let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
819+ let mut host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
814820 // Docker of the job's own, on g1t's machines (crate::docker).
815821 let docker_hosted = cfg!(target_os = "linux")
816822 && std::env::var("G1T_DOCKER").as_deref() == Ok("on")
838844 .as_u64()
839845 .or_else(|| spec["timeoutMinutes"].as_u64())
840846 .unwrap_or(60);
847+
848+ // Brokered secrets on a self-hosted runner (broker.rs): the steps'
849+ // HTTPS goes through the job's proxy, which passes requests carrying a
850+ // stand-in to g1t. A container a step starts does not get these, as it
851+ // gets none of this process's own variables.
852+ let stand_ins = broker::stand_ins(&spec);
853+ let mut broker_problem = None;
854+ if !stand_ins.is_empty() {
855+ match broker::start(stand_ins, relay, &temp.join("g1t-broker"), &base_env, Duration::from_secs(timeout * 60)) {
856+ Ok((_proxy, vars)) => {
857+ host_env.extend(broker::names(&vars));
858+ base_env.extend(vars);
859+ }
860+ Err(problem) => broker_problem = Some(format!("{problem:#}")),
861+ }
862+ }
841863 let mut job = Job {
842864 log,
843865 spec,
866888 job_context: Map::new(),
867889 built_actions: containers::Built::new(),
868890 tracking_id,
891+ broker_problem,
869892 };
870893
871894 // The workflow's env reads github, secrets, inputs and vars; the job's
10771100 if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") {
10781101 job.log.line(&format!("##[warning]{notice}"));
10791102 }
1103+ if let Some(problem) = job.broker_problem.take() {
1104+ job.log.line(&format!("##[warning]This job's brokered secrets will not be filled in: {problem}"));
1105+ }
10801106 // Actions first, then containers, as GitHub's "Set up job" and
10811107 // "Initialize containers". Each action's `pre` (read from the actions
10821108 // just downloaded) is a step of its own, "Pre <step>", before the
12261252 // environment). g1t's own sandboxes keep theirs to report a sandbox
12271253 // that stopped, and do not ask.
12281254 let rotate = std::env::var(ROTATE_TOKEN).is_ok_and(|v| v == "1");
1229− let spec = match api.spec(rotate) {
1255+ // Only a self-hosted runner's harness brokers: g1t's own sandboxes
1256+ // have their requests filled in as they leave.
1257+ let brokers = rotate && broker::possible(|name| std::env::var(name).ok());
1258+ let spec = match api.spec(rotate, brokers) {
12301259 Ok(spec) => spec,
12311260 Err(error) => {
12321261 eprintln!("g1t-runner: could not fetch the job: {error:#}");
+8−1
8484 /// everything after (`next_token`): a copy of the first left on the
8585 /// runner's computer cannot fetch the job, its secrets or a G1T_TOKEN
8686 /// again. A g1t that predates it sends none, and the token stays.
87− pub(crate) fn spec(&mut self, rotate: bool) -> Result<Value> {
87+ ///
88+ /// With `broker`, this harness passes its job's requests to brokered
89+ /// secrets' hosts through g1t (broker.rs), and the spec lists each
90+ /// stand-in's hosts (`brokered`).
91+ pub(crate) fn spec(&mut self, rotate: bool, broker: bool) -> Result<Value> {
8892 let mut body = json!({ "token": self.token });
8993 if rotate {
9094 body["rotate"] = json!(true);
9195 }
96+ if broker {
97+ body["broker"] = json!(true);
98+ }
9299 let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?;
93100 let mut spec: Value = response.into_json()?;
94101 self.take_next_token(&mut spec);
+67−10
99 //! as they leave. Anywhere else the stand-in goes as it is, which is
1010 //! useless to whoever receives it.
1111 //!
12−//! A self-hosted runner's traffic never passes the Worker, so a job there
13−//! gets the stand-in too, and a line in its log saying why it will not
14−//! work: the real value is never sent to a machine g1t does not run.
12+//! A self-hosted runner's traffic never passes the Worker, and the real
13+//! value is never sent to a machine g1t does not run. A harness that can
14+//! broker (on Linux or macOS, `JobCallArgs::broker`) is given each
15+//! stand-in's hosts in its spec (`brokered`), never a value: its job's
16+//! HTTPS to those hosts goes through a proxy of the harness's, which
17+//! passes each request carrying a stand-in to g1t
18+//! (`POST /actions/jobs/{job}/broker`), where the runner Worker fills it
19+//! in (`relayed_secrets`), sends it and scrubs the answer. Any other
20+//! harness's job keeps the stand-in, and its log says why it will not
21+//! work.
1522
1623 use g1t_contracts::actions::{BrokeredSecret, JobCallArgs, STAND_IN_PREFIX};
1724 use g1t_contracts::time::rfc3339;
18−use g1t_contracts::Outcome;
25+use g1t_contracts::{FailureCode, Outcome};
1926 use g1t_kit::now_ms;
2027 use g1t_secrets::random_hex;
2128 use serde::Deserialize;
2431 use worker::wasm_bindgen::JsValue;
2532
2633 use crate::plan::LOG_INSERT;
27−use crate::{Actions, Count, check};
34+use crate::{Actions, Count, check, fail};
2835
2936 /// The most hosts one brokered secret names.
3037 pub(crate) const MAX_HOSTS: usize = 20;
9097 made
9198 }
9299
93−/// What a job on a self-hosted runner is told for each brokered secret it
94−/// was given.
100+/// What a job on a self-hosted runner whose harness cannot broker (a
101+/// Windows one, or one from before brokering) is told for each brokered
102+/// secret it was given.
95103 pub(crate) fn self_hosted_note(name: &str) -> String {
96104 format!(
97− "##[warning]{name} is a brokered secret: this job has a stand-in for it, which g1t fills in only on its own machines for now. On a self-hosted runner, requests that use it will not be signed in."
105+ "##[warning]{name} is a brokered secret: this job has a stand-in for it, which this runner cannot have filled in (that takes an up-to-date runner whose jobs run on Linux or macOS). Requests that use it will not be signed in."
98106 )
99107 }
100108
109+/// What a job on a self-hosted runner that brokers is told for each
110+/// brokered secret it was given.
111+pub(crate) fn brokered_note(name: &str, hosts: &[String]) -> String {
112+ format!(
113+ "{name} is a brokered secret: this job's requests to {} go through g1t, which fills it in, so its value never reaches this computer.",
114+ hosts.join(", ")
115+ )
116+}
117+
118+/// What a brokering harness is given for its job's stand-ins: each one,
119+/// its secret's key (for the log) and its hosts. Never a value.
120+pub(crate) fn spec_entries(stand_ins: &[StandIn]) -> Value {
121+ Value::Array(
122+ stand_ins
123+ .iter()
124+ .map(|made| serde_json::json!({ "standIn": made.stand_in, "name": made.broker.name, "hosts": made.broker.hosts }))
125+ .collect(),
126+ )
127+}
128+
101129 /// A line of a job's log, stamped as the runner stamps its own.
102130 pub(crate) fn stamped(text: &str) -> String {
103131 let at = rfc3339(now_ms());
194222 let job = check!(self.job_for_started_token(&a).await?);
195223 Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?))
196224 }
225+
226+ /// `relayed_secrets`: a running self-hosted job's stand-ins and what
227+ /// they stand for, for the runner Worker to fill in on a request the
228+ /// job's harness passed to g1t (`POST /actions/jobs/{job}/broker`).
229+ /// Only with the job's current token, which its harness holds: the one
230+ /// it started with may have been left on the runner's computer. A job
231+ /// on g1t's machines has its requests filled in as they leave its
232+ /// sandbox, and is refused here.
233+ pub async fn relayed_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> {
234+ let job = check!(self.job_for_token(&a).await?);
235+ if job.runner_id.is_none() {
236+ return Ok(fail(FailureCode::Forbidden, "Only a self-hosted runner's job relays its requests."));
237+ }
238+ Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?))
239+ }
197240 }
198241
199242 #[cfg(test)]
201244 use rusqlite::{Connection, params};
202245 use serde_json::{Map, Value, json};
203246
204− use super::{Broker, STAND_IN_INSERT, STAND_INS_EXPIRED, STAND_INS_OF_JOB, self_hosted_note, stand_in, substitute};
247+ use super::{Broker, STAND_IN_INSERT, STAND_INS_EXPIRED, STAND_INS_OF_JOB, StandIn, brokered_note, self_hosted_note, spec_entries, stand_in, substitute};
205248
206249 fn broker(name: &str, value: &str, hosts: &[&str]) -> Broker {
207250 Broker { name: name.into(), value: value.into(), hosts: hosts.iter().map(|h| (*h).to_owned()).collect() }
273316 fn the_self_hosted_note_is_a_warning_naming_the_secret() {
274317 let note = self_hosted_note("NPM_TOKEN");
275318 assert!(note.starts_with("##[warning]NPM_TOKEN"));
276− assert!(note.contains("only on its own machines"));
319+ assert!(note.contains("cannot have filled in"));
320+ }
321+
322+ #[test]
323+ fn a_brokering_harness_gets_hosts_and_stand_ins_but_never_a_value() {
324+ let made = vec![StandIn {
325+ stand_in: "g1tsec_1".into(),
326+ broker: broker("NPM_TOKEN", "npm_realvalue123", &["registry.npmjs.org", "*.npm.example.com"]),
327+ }];
328+ let entries = spec_entries(&made);
329+ assert_eq!(entries, json!([{ "standIn": "g1tsec_1", "name": "NPM_TOKEN", "hosts": ["registry.npmjs.org", "*.npm.example.com"] }]));
330+ assert!(!entries.to_string().contains("npm_realvalue123"));
331+ let note = brokered_note("NPM_TOKEN", &made[0].broker.hosts);
332+ assert!(!note.starts_with("##["), "not a warning: {note}");
333+ assert!(note.contains("registry.npmjs.org, *.npm.example.com") && note.contains("never reaches this computer"));
277334 }
278335
279336 fn database() -> Connection {
+4−0
276276 // A running job's stand-ins for brokered secrets: the runner
277277 // Worker's alone, never passed on by the API.
278278 "brokered_secrets" => reply(&service.brokered_secrets(args(body)?).await?),
279+ // The same for a self-hosted job's broker requests, which the
280+ // runner Worker relays (`POST /actions/jobs/{job}/broker`): with
281+ // the job's current token only.
282+ "relayed_secrets" => reply(&service.relayed_secrets(args(body)?).await?),
279283 // actions/cache, through the API with the job's token.
280284 "cache_lookup" => reply(&service.cache_lookup(args(body)?).await?),
281285 "cache_reserve" => reply(&service.cache_reserve(args(body)?).await?),
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.