Merge brokered secrets on self-hosted runners: a job whose steps run on Linux or macOS, in the desktop app's sandbox, in Docker or on the computer, sends its requests for a brokered secret's hosts through g1t, which fills the value in and scrubs it from the answer, so the value never reaches the computer the job runs on; Windows jobs keep the stand-in and say so.
20 files+1499−234 files not counted0/20 viewed
| 911 | 911 | ] | |
| 912 | 912 | ||
| 913 | 913 | [[package]] | |
| 914 | + | name = "deranged" | |
| 915 | + | version = "0.5.8" | |
| 916 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 917 | + | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 918 | + | ||
| 919 | + | [[package]] | |
| 914 | 920 | name = "des" | |
| 915 | 921 | version = "0.9.0" | |
| 916 | 922 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 1533 | 1539 | "g1t-scan", | |
| 1534 | 1540 | "hex", | |
| 1535 | 1541 | "libc", | |
| 1542 | + | "rcgen", | |
| 1536 | 1543 | "regex", | |
| 1544 | + | "rustls", | |
| 1537 | 1545 | "serde", | |
| 1538 | 1546 | "serde_json", | |
| 1539 | 1547 | "serde_yaml", | |
| 1540 | 1548 | "sha2 0.10.9", | |
| 1549 | + | "time", | |
| 1541 | 1550 | "toml", | |
| 1542 | 1551 | "ureq", | |
| 1552 | + | "webpki-roots 0.26.11", | |
| 1543 | 1553 | ] | |
| 1544 | 1554 | ||
| 1545 | 1555 | [[package]] | |
| ⋯ | |||
| 2531 | 2541 | ] | |
| 2532 | 2542 | ||
| 2533 | 2543 | [[package]] | |
| 2544 | + | name = "num-conv" | |
| 2545 | + | version = "0.2.2" | |
| 2546 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2547 | + | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 2548 | + | ||
| 2549 | + | [[package]] | |
| 2534 | 2550 | name = "num-integer" | |
| 2535 | 2551 | version = "0.1.47" | |
| 2536 | 2552 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2729 | 2745 | ] | |
| 2730 | 2746 | ||
| 2731 | 2747 | [[package]] | |
| 2748 | + | name = "pem" | |
| 2749 | + | version = "3.0.6" | |
| 2750 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2751 | + | checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" | |
| 2752 | + | dependencies = [ | |
| 2753 | + | "base64 0.22.1", | |
| 2754 | + | "serde_core", | |
| 2755 | + | ] | |
| 2756 | + | ||
| 2757 | + | [[package]] | |
| 2732 | 2758 | name = "pem-rfc7468" | |
| 2733 | 2759 | version = "1.0.0" | |
| 2734 | 2760 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2909 | 2935 | ] | |
| 2910 | 2936 | ||
| 2911 | 2937 | [[package]] | |
| 2938 | + | name = "powerfmt" | |
| 2939 | + | version = "0.2.1" | |
| 2940 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2941 | + | checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" | |
| 2942 | + | ||
| 2943 | + | [[package]] | |
| 2912 | 2944 | name = "primefield" | |
| 2913 | 2945 | version = "0.14.0" | |
| 2914 | 2946 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3061 | 3093 | ] | |
| 3062 | 3094 | ||
| 3063 | 3095 | [[package]] | |
| 3096 | + | name = "rcgen" | |
| 3097 | + | version = "0.13.2" | |
| 3098 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3099 | + | checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" | |
| 3100 | + | dependencies = [ | |
| 3101 | + | "pem", | |
| 3102 | + | "ring", | |
| 3103 | + | "rustls-pki-types", | |
| 3104 | + | "time", | |
| 3105 | + | "yasna", | |
| 3106 | + | ] | |
| 3107 | + | ||
| 3108 | + | [[package]] | |
| 3064 | 3109 | name = "redox_syscall" | |
| 3065 | 3110 | version = "0.5.18" | |
| 3066 | 3111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4068 | 4113 | ] | |
| 4069 | 4114 | ||
| 4070 | 4115 | [[package]] | |
| 4116 | + | name = "time" | |
| 4117 | + | version = "0.3.55" | |
| 4118 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4119 | + | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 4120 | + | dependencies = [ | |
| 4121 | + | "deranged", | |
| 4122 | + | "num-conv", | |
| 4123 | + | "powerfmt", | |
| 4124 | + | "serde_core", | |
| 4125 | + | "time-core", | |
| 4126 | + | ] | |
| 4127 | + | ||
| 4128 | + | [[package]] | |
| 4129 | + | name = "time-core" | |
| 4130 | + | version = "0.1.9" | |
| 4131 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4132 | + | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 4133 | + | ||
| 4134 | + | [[package]] | |
| 4071 | 4135 | name = "tinystr" | |
| 4072 | 4136 | version = "0.8.4" | |
| 4073 | 4137 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 4940 | 5004 | ] | |
| 4941 | 5005 | ||
| 4942 | 5006 | [[package]] | |
| 5007 | + | name = "yasna" | |
| 5008 | + | version = "0.5.2" | |
| 5009 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5010 | + | checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" | |
| 5011 | + | dependencies = [ | |
| 5012 | + | "time", | |
| 5013 | + | ] | |
| 5014 | + | ||
| 5015 | + | [[package]] | |
| 4943 | 5016 | name = "yoke" | |
| 4944 | 5017 | version = "0.8.3" | |
| 4945 | 5018 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 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 | + | } |
| 12 | 12 | mod audit; | |
| 13 | 13 | mod billing; | |
| 14 | 14 | mod blobs; | |
| 15 | + | mod broker; | |
| 15 | 16 | mod checks; | |
| 16 | 17 | mod customize; | |
| 17 | 18 | mod deployments; | |
| ⋯ | |||
| 618 | 619 | let rest = rest.to_owned(); | |
| 619 | 620 | return blobs::for_job(request, env, &services, method, &rest).await; | |
| 620 | 621 | } | |
| 622 | + | // A self-hosted job's request to a brokered secret's host, passed to | |
| 623 | + | // g1t to be filled in (broker.rs). The job's token is the credential. | |
| 624 | + | if !on_mcp | |
| 625 | + | && method == "POST" | |
| 626 | + | && let Some(job) = broker::job_of(&path) | |
| 627 | + | { | |
| 628 | + | let job = job.to_owned(); | |
| 629 | + | return broker::relay(request, &services, &job).await; | |
| 630 | + | } | |
| 621 | 631 | ||
| 622 | 632 | // A self-hosted runner, with a registration token or its own | |
| 623 | 633 | // credential, neither of which is a g1t access token. | |
| ⋯ | |||
| 771 | 781 | // `rotate`: a harness that takes a new token with the spec and | |
| 772 | 782 | // lets go of the one it asked with (plan.rs `job_spec`). | |
| 773 | 783 | let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true); | |
| 784 | + | // `broker`: a self-hosted harness that passes its job's | |
| 785 | + | // requests to brokered hosts through g1t (broker.rs). | |
| 786 | + | let broker = method == "job_spec" && body["broker"].as_bool() == Some(true); | |
| 774 | 787 | let answered: Outcome<Value> = g1t_kit::call( | |
| 775 | 788 | &services.actions, | |
| 776 | 789 | method, | |
| 777 | − | &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }), | |
| 790 | + | &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate, "broker": broker }), | |
| 778 | 791 | ) | |
| 779 | 792 | .await?; | |
| 780 | 793 | return match answered { | |
| 1657 | 1657 | "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." | |
| 1658 | 1658 | } | |
| 1659 | 1659 | Op::SetActionsSecret => { | |
| 1660 | − | "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." | |
| 1660 | + | "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." | |
| 1661 | 1661 | } | |
| 1662 | 1662 | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 1663 | 1663 | Op::ListActionsVariables => { |
| 604 | 604 | The value never enters the job, so neither its steps nor a compromised | |
| 605 | 605 | dependency can read it. A stand-in sent to any other host goes as it is, | |
| 606 | 606 | and the log says so once. This works on g1t's machines, whether the | |
| 607 | − | project's network is restricted or open; a job on a self-hosted runner | |
| 608 | − | gets the stand-in too, never the value, and its log warns that it will | |
| 609 | − | not work there yet. A secret the job must read itself (a database | |
| 607 | + | project's network is restricted or open, and on self-hosted runners whose | |
| 608 | + | jobs run on Linux or macOS, which pass the job's requests to those hosts | |
| 609 | + | through g1t so the value never reaches the runner's computer. A job on | |
| 610 | + | Windows gets the stand-in too, never the value, and its log warns that it | |
| 611 | + | will not work there yet. A secret the job must read itself (a database | |
| 610 | 612 | password over raw TCP, a signing key, a key to files it decrypts) cannot | |
| 611 | 613 | be brokered. See | |
| 612 | 614 | [brokered secrets](/guides/secrets-and-variables/#brokered-secrets). |
| 192 | 192 | given a brokered secret, still sends its HTTP and HTTPS through the | |
| 193 | 193 | Worker, which reaches every host for it, and trusts the certificate as a | |
| 194 | 194 | 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. | |
| 197 | 201 | ||
| 198 | 202 | ## Commands | |
| 199 | 203 |
| 157 | 157 | brokered secret sends its HTTP and HTTPS through g1t either way, and | |
| 158 | 158 | trusts the certificate HTTPS is re-signed with, as a restricted one does. | |
| 159 | 159 | 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, | |
| 163 | 169 | unbrokered secret for jobs that must run there. | |
| 164 | 170 | - **Deployments** never read a brokered secret: a running app needs the | |
| 165 | 171 | value itself. A brokered secret is available to workflows only. | |
| ⋯ | |||
| 168 | 174 | The value is masked in the job's log as any secret is, should it ever | |
| 169 | 175 | appear there, and so is the stand-in. | |
| 170 | 176 | ||
| 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 | + | ||
| 171 | 234 | ### What brokering cannot cover | |
| 172 | 235 | ||
| 173 | 236 | A secret the job itself must read cannot be brokered. Keep these as | |
| 323 | 323 | The runner inside fetches the job, its secrets and its `G1T_TOKEN` | |
| 324 | 324 | itself, and sends its log back the same way, masked. None of it passes | |
| 325 | 325 | 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). | |
| 326 | 335 | - **Anything running as your account can look inside a running | |
| 327 | 336 | sandbox**, as you can: open the WSL distribution with `wsl -u root`, | |
| 328 | 337 | run a command in a Windows Sandbox, or read the VM's disk. So can an |
Binary or large file; its contents are not shown.
| 997 | 997 | /// that predate it never ask, and keep their token. | |
| 998 | 998 | #[serde(default)] | |
| 999 | 999 | 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, | |
| 1000 | 1007 | } | |
| 1001 | 1008 | ||
| 1002 | 1009 | /// What the runner needs to start a job's sandbox. |
| 27 | 27 | # native-certs: a guarded sandbox re-signs HTTPS with a certificate the | |
| 28 | 28 | # runner adds to the system store (guard.rs), which webpki-roots never sees. | |
| 29 | 29 | 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"] } | |
| 30 | 39 | ||
| 31 | 40 | [target.'cfg(unix)'.dependencies] | |
| 32 | 41 | # A step's signals back to their defaults before it runs (actions/process.rs). |
A large diff (1,142 lines) is not shown until you ask for it.
| 9 | 9 | //! job's definition, its contexts and its secrets, is fetched with them. | |
| 10 | 10 | ||
| 11 | 11 | mod blobs; | |
| 12 | + | mod broker; | |
| 12 | 13 | mod containers; | |
| 13 | 14 | mod files; | |
| 14 | 15 | mod glob; | |
| ⋯ | |||
| 116 | 117 | pub(crate) built_actions: containers::Built, | |
| 117 | 118 | /// `RUNNER_TRACKING_ID`, which every process a step starts inherits. | |
| 118 | 119 | 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>, | |
| 119 | 123 | } | |
| 120 | 124 | ||
| 121 | 125 | fn text_map(value: Option<&Value>) -> BTreeMap<String, String> { | |
| ⋯ | |||
| 796 | 800 | /// [`setup`] with the workspace and temporary folder given: a test's own. | |
| 797 | 801 | fn setup_in(mut spec: Value, api: Api, workspace: PathBuf, temp: PathBuf) -> Result<Job> { | |
| 798 | 802 | 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); | |
| 799 | 805 | let mut log = Log::new(api, masks); | |
| 800 | 806 | // Variables GitHub sets that a g1t from before them does not send. | |
| 801 | 807 | default_variables(&mut spec); | |
| ⋯ | |||
| 810 | 816 | std::fs::create_dir_all(&temp).context("could not make the temporary folder")?; | |
| 811 | 817 | std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?; | |
| 812 | 818 | ||
| 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(); | |
| 814 | 820 | // Docker of the job's own, on g1t's machines (crate::docker). | |
| 815 | 821 | let docker_hosted = cfg!(target_os = "linux") | |
| 816 | 822 | && std::env::var("G1T_DOCKER").as_deref() == Ok("on") | |
| ⋯ | |||
| 838 | 844 | .as_u64() | |
| 839 | 845 | .or_else(|| spec["timeoutMinutes"].as_u64()) | |
| 840 | 846 | .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 | + | } | |
| 841 | 863 | let mut job = Job { | |
| 842 | 864 | log, | |
| 843 | 865 | spec, | |
| ⋯ | |||
| 866 | 888 | job_context: Map::new(), | |
| 867 | 889 | built_actions: containers::Built::new(), | |
| 868 | 890 | tracking_id, | |
| 891 | + | broker_problem, | |
| 869 | 892 | }; | |
| 870 | 893 | ||
| 871 | 894 | // The workflow's env reads github, secrets, inputs and vars; the job's | |
| ⋯ | |||
| 1077 | 1100 | if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") { | |
| 1078 | 1101 | job.log.line(&format!("##[warning]{notice}")); | |
| 1079 | 1102 | } | |
| 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 | + | } | |
| 1080 | 1106 | // Actions first, then containers, as GitHub's "Set up job" and | |
| 1081 | 1107 | // "Initialize containers". Each action's `pre` (read from the actions | |
| 1082 | 1108 | // just downloaded) is a step of its own, "Pre <step>", before the | |
| ⋯ | |||
| 1226 | 1252 | // environment). g1t's own sandboxes keep theirs to report a sandbox | |
| 1227 | 1253 | // that stopped, and do not ask. | |
| 1228 | 1254 | 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) { | |
| 1230 | 1259 | Ok(spec) => spec, | |
| 1231 | 1260 | Err(error) => { | |
| 1232 | 1261 | eprintln!("g1t-runner: could not fetch the job: {error:#}"); | |
| 84 | 84 | /// everything after (`next_token`): a copy of the first left on the | |
| 85 | 85 | /// runner's computer cannot fetch the job, its secrets or a G1T_TOKEN | |
| 86 | 86 | /// 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> { | |
| 88 | 92 | let mut body = json!({ "token": self.token }); | |
| 89 | 93 | if rotate { | |
| 90 | 94 | body["rotate"] = json!(true); | |
| 91 | 95 | } | |
| 96 | + | if broker { | |
| 97 | + | body["broker"] = json!(true); | |
| 98 | + | } | |
| 92 | 99 | let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?; | |
| 93 | 100 | let mut spec: Value = response.into_json()?; | |
| 94 | 101 | self.take_next_token(&mut spec); |
| 9 | 9 | //! as they leave. Anywhere else the stand-in goes as it is, which is | |
| 10 | 10 | //! useless to whoever receives it. | |
| 11 | 11 | //! | |
| 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. | |
| 15 | 22 | ||
| 16 | 23 | use g1t_contracts::actions::{BrokeredSecret, JobCallArgs, STAND_IN_PREFIX}; | |
| 17 | 24 | use g1t_contracts::time::rfc3339; | |
| 18 | − | use g1t_contracts::Outcome; | |
| 25 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 19 | 26 | use g1t_kit::now_ms; | |
| 20 | 27 | use g1t_secrets::random_hex; | |
| 21 | 28 | use serde::Deserialize; | |
| ⋯ | |||
| 24 | 31 | use worker::wasm_bindgen::JsValue; | |
| 25 | 32 | ||
| 26 | 33 | use crate::plan::LOG_INSERT; | |
| 27 | − | use crate::{Actions, Count, check}; | |
| 34 | + | use crate::{Actions, Count, check, fail}; | |
| 28 | 35 | ||
| 29 | 36 | /// The most hosts one brokered secret names. | |
| 30 | 37 | pub(crate) const MAX_HOSTS: usize = 20; | |
| ⋯ | |||
| 90 | 97 | made | |
| 91 | 98 | } | |
| 92 | 99 | ||
| 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. | |
| 95 | 103 | pub(crate) fn self_hosted_note(name: &str) -> String { | |
| 96 | 104 | 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." | |
| 98 | 106 | ) | |
| 99 | 107 | } | |
| 100 | 108 | ||
| 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 | + | ||
| 101 | 129 | /// A line of a job's log, stamped as the runner stamps its own. | |
| 102 | 130 | pub(crate) fn stamped(text: &str) -> String { | |
| 103 | 131 | let at = rfc3339(now_ms()); | |
| ⋯ | |||
| 194 | 222 | let job = check!(self.job_for_started_token(&a).await?); | |
| 195 | 223 | Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?)) | |
| 196 | 224 | } | |
| 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 | + | } | |
| 197 | 240 | } | |
| 198 | 241 | ||
| 199 | 242 | #[cfg(test)] | |
| ⋯ | |||
| 201 | 244 | use rusqlite::{Connection, params}; | |
| 202 | 245 | use serde_json::{Map, Value, json}; | |
| 203 | 246 | ||
| 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}; | |
| 205 | 248 | ||
| 206 | 249 | fn broker(name: &str, value: &str, hosts: &[&str]) -> Broker { | |
| 207 | 250 | Broker { name: name.into(), value: value.into(), hosts: hosts.iter().map(|h| (*h).to_owned()).collect() } | |
| ⋯ | |||
| 273 | 316 | fn the_self_hosted_note_is_a_warning_naming_the_secret() { | |
| 274 | 317 | let note = self_hosted_note("NPM_TOKEN"); | |
| 275 | 318 | 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")); | |
| 277 | 334 | } | |
| 278 | 335 | ||
| 279 | 336 | fn database() -> Connection { | |
| 280 | 280 | // A running job's stand-ins for brokered secrets: the runner | |
| 281 | 281 | // Worker's alone, never passed on by the API. | |
| 282 | 282 | "brokered_secrets" => reply(&service.brokered_secrets(args(body)?).await?), | |
| 283 | + | // The same for a self-hosted job's broker requests, which the | |
| 284 | + | // runner Worker relays (`POST /actions/jobs/{job}/broker`): with | |
| 285 | + | // the job's current token only. | |
| 286 | + | "relayed_secrets" => reply(&service.relayed_secrets(args(body)?).await?), | |
| 283 | 287 | // actions/cache, through the API with the job's token. | |
| 284 | 288 | "cache_lookup" => reply(&service.cache_lookup(args(body)?).await?), | |
| 285 | 289 | "cache_reserve" => reply(&service.cache_reserve(args(body)?).await?), |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…