feat(runner): opt-in guest access to the forge host:port of ERMITE_RUNNER_URL
ERMITE_RUNNER_GUEST_FORGE=1 points sandboxes at the forge the way the runner reaches it: DNS override, one nft accept, and the server URLs of the job rewritten to the ERMITE_RUNNER_URL origin. Staging only.
- commit
- 669924454014efc26c64c23e656ab307ae239c96
- author
- brice · · 2026-10-13 07:19 UTC
- parent
- 853c006
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +806 -10
M README.md +17 -2
| @@ -37,10 +37,23 @@ Reaping removes every process, device, firewall rule and Docker object the runne | |||
| 37 | 37 | ||
| 38 | 38 | ### Network policy (both modes) | |
| 39 | 39 | ||
| 40 | - | One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection. | |
| 40 | + | One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address (plus one TCP port when the opt-in guest forge access is on, below), where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection. | |
| 41 | 41 | ||
| 42 | 42 | **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes). | |
| 43 | 43 | ||
| 44 | + | ### Guest forge access (opt-in, staging only) | |
| 45 | + | ||
| 46 | + | Off by default. `ERMITE_RUNNER_GUEST_FORGE=1` lets sandboxes reach the forge **the way the runner does**, for a staging host where ermite is only reachable through an overlay (NetBird) and the runner itself uses a local route, e.g. `/etc/hosts` `ermite.bnema.dev -> 127.0.0.1` and a reverse proxy (Gordon) on `*:8088` with `ERMITE_RUNNER_URL=http://ermite.bnema.dev:8088`. Jobs otherwise cannot `git clone ${{ github.server_url }}/...`: the public URL (`https://ermite.bnema.dev`) is TLS-terminated off-host, and the private ranges are denied. | |
| 47 | + | ||
| 48 | + | The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the scheme: 443 or 80). The runner **refuses to start** when the URL host is an IP literal (sandboxes are pointed at the host by DNS), when the URL has a path, when the name does not resolve on the host, or when the URL is plain `http://` without `ERMITE_RUNNER_INSECURE_HTTP=1` (same rule as for the runner itself: tokens then travel in clear). Then, for every sandbox in both modes: | |
| 49 | + | ||
| 50 | + | - the DNS forwarder answers `A` queries for exactly that host name (case-insensitive, trailing dot ignored) with the sandbox's **gateway address**, and `AAAA` with an empty `NOERROR` answer; every other name is forwarded as before; | |
| 51 | + | - the nftables input chain accepts TCP from the sandbox's own address to its own gateway address on that one port, before the sandbox's catch-all drop (one extra line per sandbox; see `internal/netpolicy/testdata/guest_forge.nft`). Nothing else on the host opens; the forward chain (private-range denies, source check, no sandbox-to-sandbox) is unchanged; | |
| 52 | + | - the `github.server_url`, `github.api_url` and `github.graphql_url` of the job's contexts, and so `GITHUB_SERVER_URL`, `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL`, are rewritten to the origin of `ERMITE_RUNNER_URL` when their origin is the server's public one (`github.server_url`); paths are kept (`/-/github/api`). Other values, and the URLs inside `github.event`, stay as the server recorded them; | |
| 53 | + | - `guest forge access: sandboxes may reach <origin> on the host` is logged at start and at the start of every job, like the privileged-container warning. | |
| 54 | + | ||
| 55 | + | **The runner does not proxy.** The traffic reaches the host's own listener on that port, so a service must listen on it on an address the gateway reaches (`*`, as Gordon does; a listener bound to `127.0.0.1` receives nothing). A shared reverse proxy on that port routes by `Host` header, so **a job can reach every app that proxy serves on that port**, not only the forge, by sending another `Host` header: use this on staging hosts only, with a proxy whose other apps you accept to expose to job code. Jobs also see the forge over plain HTTP in that setup. | |
| 56 | + | ||
| 44 | 57 | ### Cancellation | |
| 45 | 58 | ||
| 46 | 59 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | |
| @@ -96,7 +109,8 @@ the host process; the sandbox receives the workflow and its contexts only. | |||
| 96 | 109 | | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) | | |
| 97 | 110 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners | | |
| 98 | 111 | | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) | | |
| 99 | - | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) | | |
| 112 | + | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only); also required for `ERMITE_RUNNER_GUEST_FORGE` with an `http://` URL | | |
| 113 | + | | `ERMITE_RUNNER_GUEST_FORGE` | `1` lets sandboxes reach the forge as the runner does (staging only; see "Guest forge access") | | |
| 100 | 114 | | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` | | |
| 101 | 115 | | `ERMITE_RUNNER_LOG_FORMAT` | `console` (default, colours) or `json` | | |
| 102 | 116 | ||
| @@ -246,6 +260,7 @@ the moment it starts. | |||
| 246 | 260 | | `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T`. A warning is logged at start when the state directory has less free space than capacity x disk (disks are sparse: it is only a warning) | | |
| 247 | 261 | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit | | |
| 248 | 262 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox | | |
| 263 | + | | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the host and port of `ERMITE_RUNNER_URL` on the host (staging only, see "Guest forge access") | | |
| 249 | 264 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | |
| 250 | 265 | | `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables | | |
| 251 | 266 | | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start | | |
| @@ -37,10 +37,23 @@ Reaping removes every process, device, firewall rule and Docker object the runne | |||||
| 37 | 37 | ||||
| 38 | ### Network policy (both modes) | 38 | ### Network policy (both modes) | ||
| 39 | 39 | ||||
| 40 | - | One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection. | 40 | + | One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address (plus one TCP port when the opt-in guest forge access is on, below), where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection. |
| 41 | 41 | ||||
| 42 | **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes). | 42 | **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes). | ||
| 43 | 43 | ||||
| 44 | + | ### Guest forge access (opt-in, staging only) | |||
| 45 | + | ||||
| 46 | + | Off by default. `ERMITE_RUNNER_GUEST_FORGE=1` lets sandboxes reach the forge **the way the runner does**, for a staging host where ermite is only reachable through an overlay (NetBird) and the runner itself uses a local route, e.g. `/etc/hosts` `ermite.bnema.dev -> 127.0.0.1` and a reverse proxy (Gordon) on `*:8088` with `ERMITE_RUNNER_URL=http://ermite.bnema.dev:8088`. Jobs otherwise cannot `git clone ${{ github.server_url }}/...`: the public URL (`https://ermite.bnema.dev`) is TLS-terminated off-host, and the private ranges are denied. | |||
| 47 | + | ||||
| 48 | + | The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the scheme: 443 or 80). The runner **refuses to start** when the URL host is an IP literal (sandboxes are pointed at the host by DNS), when the URL has a path, when the name does not resolve on the host, or when the URL is plain `http://` without `ERMITE_RUNNER_INSECURE_HTTP=1` (same rule as for the runner itself: tokens then travel in clear). Then, for every sandbox in both modes: | |||
| 49 | + | ||||
| 50 | + | - the DNS forwarder answers `A` queries for exactly that host name (case-insensitive, trailing dot ignored) with the sandbox's **gateway address**, and `AAAA` with an empty `NOERROR` answer; every other name is forwarded as before; | |||
| 51 | + | - the nftables input chain accepts TCP from the sandbox's own address to its own gateway address on that one port, before the sandbox's catch-all drop (one extra line per sandbox; see `internal/netpolicy/testdata/guest_forge.nft`). Nothing else on the host opens; the forward chain (private-range denies, source check, no sandbox-to-sandbox) is unchanged; | |||
| 52 | + | - the `github.server_url`, `github.api_url` and `github.graphql_url` of the job's contexts, and so `GITHUB_SERVER_URL`, `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL`, are rewritten to the origin of `ERMITE_RUNNER_URL` when their origin is the server's public one (`github.server_url`); paths are kept (`/-/github/api`). Other values, and the URLs inside `github.event`, stay as the server recorded them; | |||
| 53 | + | - `guest forge access: sandboxes may reach <origin> on the host` is logged at start and at the start of every job, like the privileged-container warning. | |||
| 54 | + | ||||
| 55 | + | **The runner does not proxy.** The traffic reaches the host's own listener on that port, so a service must listen on it on an address the gateway reaches (`*`, as Gordon does; a listener bound to `127.0.0.1` receives nothing). A shared reverse proxy on that port routes by `Host` header, so **a job can reach every app that proxy serves on that port**, not only the forge, by sending another `Host` header: use this on staging hosts only, with a proxy whose other apps you accept to expose to job code. Jobs also see the forge over plain HTTP in that setup. | |||
| 56 | + | ||||
| 44 | ### Cancellation | 57 | ### Cancellation | ||
| 45 | 58 | ||||
| 46 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | 59 | The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel channel, not a context). The executor signals the script's process group like the official runner: SIGINT at once, SIGTERM after 7.5 s, SIGKILL 2.5 s later, with the timers stopped as soon as the shell exits and nothing signalled afterwards (the group id could be reused). Background children that keep the output pipes open are killed when the shell exits, after a short drain. Init forwards SIGTERM and SIGINT to the executor. | ||
| @@ -96,7 +109,8 @@ the host process; the sandbox receives the workflow and its contexts only. | |||||
| 96 | | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) | | 109 | | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) | | ||
| 97 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners | | 110 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` (default) or `self-hosted`: the `runner.environment` value the jobs see. ermite's runners stand in for GitHub-hosted ones, so workflows written for github.com (`runner.environment == 'github-hosted'`) behave the same; `self-hosted` also makes "Set up job" print the runner and machine name, as GitHub does for self-hosted runners | | ||
| 98 | | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) | | 111 | | `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` (see above) | | ||
| 99 | - | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only) | | 112 | + | | `ERMITE_RUNNER_INSECURE_HTTP` | `1` allows a non-loopback `http://` URL (tests only); also required for `ERMITE_RUNNER_GUEST_FORGE` with an `http://` URL | |
| 113 | + | | `ERMITE_RUNNER_GUEST_FORGE` | `1` lets sandboxes reach the forge as the runner does (staging only; see "Guest forge access") | | |||
| 100 | | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` | | 114 | | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` | | ||
| 101 | | `ERMITE_RUNNER_LOG_FORMAT` | `console` (default, colours) or `json` | | 115 | | `ERMITE_RUNNER_LOG_FORMAT` | `console` (default, colours) or `json` | | ||
| 102 | 116 | ||||
| @@ -246,6 +260,7 @@ the moment it starts. | |||||
| 246 | | `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T`. A warning is logged at start when the state directory has less free space than capacity x disk (disks are sparse: it is only a warning) | | 260 | | `ERMITE_RUNNER_JOB_CPUS` / `_JOB_MEMORY` / `_JOB_DISK` | `2` / `4G` / `64G` | per job; sizes accept `M`, `G`, `T`. A warning is logged at start when the state directory has less free space than capacity x disk (disks are sparse: it is only a warning) | | ||
| 247 | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit | | 261 | | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit | | ||
| 248 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox | | 262 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses container mode without sysbox | | ||
| 263 | + | | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the host and port of `ERMITE_RUNNER_URL` on the host (staging only, see "Guest forge access") | | |||
| 249 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | 264 | | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) | | ||
| 250 | | `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables | | 265 | | `ERMITE_RUNNER_JOB_DISK_BANDWIDTH`, `_JOB_DISK_IOPS`, `_JOB_NET_BANDWIDTH` | `400`, `20000`, `1000` | VM rate limits in MiB/s, IOPS and Mbit/s; 0 disables | | ||
| 251 | | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start | | 266 | | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start | | ||
M host_cmd.go +12 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "flag" | |
| 9 | 9 | "fmt" | |
| 10 | + | "net" | |
| 10 | 11 | "os" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| @@ -104,6 +105,17 @@ func runCmd(ctx context.Context) int { | |||
| 104 | 105 | fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL) | |
| 105 | 106 | return 2 | |
| 106 | 107 | } | |
| 108 | + | if scfg.GuestForgeEnabled { | |
| 109 | + | // The sandboxes reach the forge the way the runner does: by the host name | |
| 110 | + | // and port of the URL the runner itself uses. | |
| 111 | + | gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) | |
| 112 | + | if err != nil { | |
| 113 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| 114 | + | return 2 | |
| 115 | + | } | |
| 116 | + | scfg.GuestForge = gf | |
| 117 | + | hcfg.GuestForgeOrigin = gf.Origin | |
| 118 | + | } | |
| 107 | 119 | drv, err := buildDriver(ctx, scfg) | |
| 108 | 120 | if err != nil { | |
| 109 | 121 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "flag" | 8 | "flag" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | + | "net" | |||
| 10 | "os" | 11 | "os" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| @@ -104,6 +105,17 @@ func runCmd(ctx context.Context) int { | |||||
| 104 | fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL) | 105 | fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL) | ||
| 105 | return 2 | 106 | return 2 | ||
| 106 | } | 107 | } | ||
| 108 | + | if scfg.GuestForgeEnabled { | |||
| 109 | + | // The sandboxes reach the forge the way the runner does: by the host name | |||
| 110 | + | // and port of the URL the runner itself uses. | |||
| 111 | + | gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) | |||
| 112 | + | if err != nil { | |||
| 113 | + | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |||
| 114 | + | return 2 | |||
| 115 | + | } | |||
| 116 | + | scfg.GuestForge = gf | |||
| 117 | + | hcfg.GuestForgeOrigin = gf.Origin | |||
| 118 | + | } | |||
| 107 | drv, err := buildDriver(ctx, scfg) | 119 | drv, err := buildDriver(ctx, scfg) | ||
| 108 | if err != nil { | 120 | if err != nil { | ||
| 109 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | 121 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | ||
M internal/host/config.go +5 -0
| @@ -62,6 +62,11 @@ type Config struct { | |||
| 62 | 62 | Environment string | |
| 63 | 63 | // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1). | |
| 64 | 64 | InsecureHTTP bool | |
| 65 | + | // GuestForgeOrigin is the origin (scheme://host[:port]) of ERMITE_RUNNER_URL | |
| 66 | + | // when ERMITE_RUNNER_GUEST_FORGE=1, "" otherwise. The server URLs of every | |
| 67 | + | // job are rewritten to it, see RewriteServerURLs. Set by the caller after | |
| 68 | + | // sandbox.ResolveGuestForge. | |
| 69 | + | GuestForgeOrigin string | |
| 65 | 70 | } | |
| 66 | 71 | ||
| 67 | 72 | // Client returns the client for a token, honouring InsecureHTTP. | |
| @@ -62,6 +62,11 @@ type Config struct { | |||||
| 62 | Environment string | 62 | Environment string | ||
| 63 | // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1). | 63 | // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1). | ||
| 64 | InsecureHTTP bool | 64 | InsecureHTTP bool | ||
| 65 | + | // GuestForgeOrigin is the origin (scheme://host[:port]) of ERMITE_RUNNER_URL | |||
| 66 | + | // when ERMITE_RUNNER_GUEST_FORGE=1, "" otherwise. The server URLs of every | |||
| 67 | + | // job are rewritten to it, see RewriteServerURLs. Set by the caller after | |||
| 68 | + | // sandbox.ResolveGuestForge. | |||
| 69 | + | GuestForgeOrigin string | |||
| 65 | } | 70 | } | ||
| 66 | 71 | ||||
| 67 | // Client returns the client for a token, honouring InsecureHTTP. | 72 | // Client returns the client for a token, honouring InsecureHTTP. | ||
A internal/host/guestforge.go +189 -0
| @@ -0,0 +1,189 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "net/url" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // serverURLKeys are the keys of the github context that actions use to reach | |
| 16 | + | // the forge: the server's web URL, its REST API and its GraphQL API. The | |
| 17 | + | // environment variables GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_GRAPHQL_URL | |
| 18 | + | // are derived from them by the executor (State.RuntimeEnv), so rewriting the | |
| 19 | + | // context rewrites the environment too. Everything else that carries the | |
| 20 | + | // origin (the URLs inside github.event, recorded as the server built them; | |
| 21 | + | // repositoryUrl, which is a git:// URL) is left alone. | |
| 22 | + | var serverURLKeys = []string{"server_url", "api_url", "graphql_url"} | |
| 23 | + | ||
| 24 | + | type jsonMember struct { | |
| 25 | + | key string | |
| 26 | + | val json.RawMessage | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // RewriteServerURLs returns the contexts JSON of a job with the origin of | |
| 30 | + | // github.server_url, github.api_url and github.graphql_url replaced by target | |
| 31 | + | // (scheme://host[:port], the ERMITE_RUNNER_URL origin) when, and only when, the | |
| 32 | + | // value has the same origin as github.server_url, the server's public origin. | |
| 33 | + | // Paths, queries and fragments are kept ("/-/github/api"); values with another | |
| 34 | + | // origin, with credentials, or that are not strings are untouched, as is the | |
| 35 | + | // rest of the document (order of keys included). The input is returned as it | |
| 36 | + | // is when nothing changes. | |
| 37 | + | func RewriteServerURLs(contexts, target string) (string, error) { | |
| 38 | + | if target == "" { | |
| 39 | + | return contexts, nil | |
| 40 | + | } | |
| 41 | + | top, err := parseObject([]byte(contexts)) | |
| 42 | + | if err != nil { | |
| 43 | + | return "", fmt.Errorf("job contexts: %w", err) | |
| 44 | + | } | |
| 45 | + | gi := -1 | |
| 46 | + | for i, m := range top { | |
| 47 | + | if m.key == "github" { | |
| 48 | + | gi = i | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if gi < 0 { | |
| 52 | + | return contexts, nil | |
| 53 | + | } | |
| 54 | + | gh, err := parseObject(top[gi].val) | |
| 55 | + | if err != nil { | |
| 56 | + | return "", fmt.Errorf("job contexts: github: %w", err) | |
| 57 | + | } | |
| 58 | + | str := func(raw json.RawMessage) (string, bool) { | |
| 59 | + | var s string | |
| 60 | + | return s, json.Unmarshal(raw, &s) == nil | |
| 61 | + | } | |
| 62 | + | var public string | |
| 63 | + | for _, m := range gh { | |
| 64 | + | if m.key == "server_url" { | |
| 65 | + | if s, ok := str(m.val); ok { | |
| 66 | + | public = originOf(s) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if public == "" || public == originOf(target) { | |
| 71 | + | return contexts, nil | |
| 72 | + | } | |
| 73 | + | changed := false | |
| 74 | + | for i, m := range gh { | |
| 75 | + | isURL := false | |
| 76 | + | for _, k := range serverURLKeys { | |
| 77 | + | isURL = isURL || m.key == k | |
| 78 | + | } | |
| 79 | + | if !isURL { | |
| 80 | + | continue | |
| 81 | + | } | |
| 82 | + | s, ok := str(m.val) | |
| 83 | + | if !ok || originOf(s) != public { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | rest := s[strings.Index(s, "://")+3:] | |
| 87 | + | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | |
| 88 | + | rest = rest[k:] | |
| 89 | + | } else { | |
| 90 | + | rest = "" | |
| 91 | + | } | |
| 92 | + | nv, err := marshalNoEscape(target + rest) | |
| 93 | + | if err != nil { | |
| 94 | + | return "", err | |
| 95 | + | } | |
| 96 | + | gh[i].val = nv | |
| 97 | + | changed = true | |
| 98 | + | } | |
| 99 | + | if !changed { | |
| 100 | + | return contexts, nil | |
| 101 | + | } | |
| 102 | + | if top[gi].val, err = encodeObject(gh); err != nil { | |
| 103 | + | return "", err | |
| 104 | + | } | |
| 105 | + | out, err := encodeObject(top) | |
| 106 | + | if err != nil { | |
| 107 | + | return "", err | |
| 108 | + | } | |
| 109 | + | return string(out), nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | |
| 113 | + | // without the default port, or "" for anything else (including URLs with | |
| 114 | + | // credentials, which are never rewritten). | |
| 115 | + | func originOf(raw string) string { | |
| 116 | + | u, err := url.Parse(raw) | |
| 117 | + | if err != nil || u.Host == "" || u.User != nil { | |
| 118 | + | return "" | |
| 119 | + | } | |
| 120 | + | scheme := strings.ToLower(u.Scheme) | |
| 121 | + | if scheme != "http" && scheme != "https" { | |
| 122 | + | return "" | |
| 123 | + | } | |
| 124 | + | host, port := strings.ToLower(u.Hostname()), u.Port() | |
| 125 | + | if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { | |
| 126 | + | return scheme + "://" + host | |
| 127 | + | } | |
| 128 | + | return scheme + "://" + host + ":" + port | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func marshalNoEscape(v any) (json.RawMessage, error) { | |
| 132 | + | var b bytes.Buffer | |
| 133 | + | enc := json.NewEncoder(&b) | |
| 134 | + | enc.SetEscapeHTML(false) | |
| 135 | + | if err := enc.Encode(v); err != nil { | |
| 136 | + | return nil, err | |
| 137 | + | } | |
| 138 | + | return bytes.TrimRight(b.Bytes(), "\n"), nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // parseObject reads a JSON object into its members, in order. | |
| 142 | + | func parseObject(b []byte) ([]jsonMember, error) { | |
| 143 | + | dec := json.NewDecoder(bytes.NewReader(b)) | |
| 144 | + | if t, err := dec.Token(); err != nil || t != json.Delim('{') { | |
| 145 | + | return nil, errors.New("not a JSON object") | |
| 146 | + | } | |
| 147 | + | var out []jsonMember | |
| 148 | + | for dec.More() { | |
| 149 | + | kt, err := dec.Token() | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, err | |
| 152 | + | } | |
| 153 | + | k, ok := kt.(string) | |
| 154 | + | if !ok { | |
| 155 | + | return nil, errors.New("not a JSON object") | |
| 156 | + | } | |
| 157 | + | var v json.RawMessage | |
| 158 | + | if err := dec.Decode(&v); err != nil { | |
| 159 | + | return nil, err | |
| 160 | + | } | |
| 161 | + | out = append(out, jsonMember{k, v}) | |
| 162 | + | } | |
| 163 | + | if _, err := dec.Token(); err != nil { | |
| 164 | + | return nil, err | |
| 165 | + | } | |
| 166 | + | if _, err := dec.Token(); err != io.EOF { | |
| 167 | + | return nil, errors.New("data after the JSON object") | |
| 168 | + | } | |
| 169 | + | return out, nil | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func encodeObject(ms []jsonMember) (json.RawMessage, error) { | |
| 173 | + | var b bytes.Buffer | |
| 174 | + | b.WriteByte('{') | |
| 175 | + | for i, m := range ms { | |
| 176 | + | if i > 0 { | |
| 177 | + | b.WriteByte(',') | |
| 178 | + | } | |
| 179 | + | k, err := marshalNoEscape(m.key) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | b.Write(k) | |
| 184 | + | b.WriteByte(':') | |
| 185 | + | b.Write(m.val) | |
| 186 | + | } | |
| 187 | + | b.WriteByte('}') | |
| 188 | + | return b.Bytes(), nil | |
| 189 | + | } | |
| @@ -0,0 +1,189 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "net/url" | |||
| 12 | + | "strings" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // serverURLKeys are the keys of the github context that actions use to reach | |||
| 16 | + | // the forge: the server's web URL, its REST API and its GraphQL API. The | |||
| 17 | + | // environment variables GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_GRAPHQL_URL | |||
| 18 | + | // are derived from them by the executor (State.RuntimeEnv), so rewriting the | |||
| 19 | + | // context rewrites the environment too. Everything else that carries the | |||
| 20 | + | // origin (the URLs inside github.event, recorded as the server built them; | |||
| 21 | + | // repositoryUrl, which is a git:// URL) is left alone. | |||
| 22 | + | var serverURLKeys = []string{"server_url", "api_url", "graphql_url"} | |||
| 23 | + | ||||
| 24 | + | type jsonMember struct { | |||
| 25 | + | key string | |||
| 26 | + | val json.RawMessage | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // RewriteServerURLs returns the contexts JSON of a job with the origin of | |||
| 30 | + | // github.server_url, github.api_url and github.graphql_url replaced by target | |||
| 31 | + | // (scheme://host[:port], the ERMITE_RUNNER_URL origin) when, and only when, the | |||
| 32 | + | // value has the same origin as github.server_url, the server's public origin. | |||
| 33 | + | // Paths, queries and fragments are kept ("/-/github/api"); values with another | |||
| 34 | + | // origin, with credentials, or that are not strings are untouched, as is the | |||
| 35 | + | // rest of the document (order of keys included). The input is returned as it | |||
| 36 | + | // is when nothing changes. | |||
| 37 | + | func RewriteServerURLs(contexts, target string) (string, error) { | |||
| 38 | + | if target == "" { | |||
| 39 | + | return contexts, nil | |||
| 40 | + | } | |||
| 41 | + | top, err := parseObject([]byte(contexts)) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return "", fmt.Errorf("job contexts: %w", err) | |||
| 44 | + | } | |||
| 45 | + | gi := -1 | |||
| 46 | + | for i, m := range top { | |||
| 47 | + | if m.key == "github" { | |||
| 48 | + | gi = i | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | if gi < 0 { | |||
| 52 | + | return contexts, nil | |||
| 53 | + | } | |||
| 54 | + | gh, err := parseObject(top[gi].val) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return "", fmt.Errorf("job contexts: github: %w", err) | |||
| 57 | + | } | |||
| 58 | + | str := func(raw json.RawMessage) (string, bool) { | |||
| 59 | + | var s string | |||
| 60 | + | return s, json.Unmarshal(raw, &s) == nil | |||
| 61 | + | } | |||
| 62 | + | var public string | |||
| 63 | + | for _, m := range gh { | |||
| 64 | + | if m.key == "server_url" { | |||
| 65 | + | if s, ok := str(m.val); ok { | |||
| 66 | + | public = originOf(s) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if public == "" || public == originOf(target) { | |||
| 71 | + | return contexts, nil | |||
| 72 | + | } | |||
| 73 | + | changed := false | |||
| 74 | + | for i, m := range gh { | |||
| 75 | + | isURL := false | |||
| 76 | + | for _, k := range serverURLKeys { | |||
| 77 | + | isURL = isURL || m.key == k | |||
| 78 | + | } | |||
| 79 | + | if !isURL { | |||
| 80 | + | continue | |||
| 81 | + | } | |||
| 82 | + | s, ok := str(m.val) | |||
| 83 | + | if !ok || originOf(s) != public { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | rest := s[strings.Index(s, "://")+3:] | |||
| 87 | + | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | |||
| 88 | + | rest = rest[k:] | |||
| 89 | + | } else { | |||
| 90 | + | rest = "" | |||
| 91 | + | } | |||
| 92 | + | nv, err := marshalNoEscape(target + rest) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return "", err | |||
| 95 | + | } | |||
| 96 | + | gh[i].val = nv | |||
| 97 | + | changed = true | |||
| 98 | + | } | |||
| 99 | + | if !changed { | |||
| 100 | + | return contexts, nil | |||
| 101 | + | } | |||
| 102 | + | if top[gi].val, err = encodeObject(gh); err != nil { | |||
| 103 | + | return "", err | |||
| 104 | + | } | |||
| 105 | + | out, err := encodeObject(top) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return "", err | |||
| 108 | + | } | |||
| 109 | + | return string(out), nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | |||
| 113 | + | // without the default port, or "" for anything else (including URLs with | |||
| 114 | + | // credentials, which are never rewritten). | |||
| 115 | + | func originOf(raw string) string { | |||
| 116 | + | u, err := url.Parse(raw) | |||
| 117 | + | if err != nil || u.Host == "" || u.User != nil { | |||
| 118 | + | return "" | |||
| 119 | + | } | |||
| 120 | + | scheme := strings.ToLower(u.Scheme) | |||
| 121 | + | if scheme != "http" && scheme != "https" { | |||
| 122 | + | return "" | |||
| 123 | + | } | |||
| 124 | + | host, port := strings.ToLower(u.Hostname()), u.Port() | |||
| 125 | + | if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { | |||
| 126 | + | return scheme + "://" + host | |||
| 127 | + | } | |||
| 128 | + | return scheme + "://" + host + ":" + port | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func marshalNoEscape(v any) (json.RawMessage, error) { | |||
| 132 | + | var b bytes.Buffer | |||
| 133 | + | enc := json.NewEncoder(&b) | |||
| 134 | + | enc.SetEscapeHTML(false) | |||
| 135 | + | if err := enc.Encode(v); err != nil { | |||
| 136 | + | return nil, err | |||
| 137 | + | } | |||
| 138 | + | return bytes.TrimRight(b.Bytes(), "\n"), nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // parseObject reads a JSON object into its members, in order. | |||
| 142 | + | func parseObject(b []byte) ([]jsonMember, error) { | |||
| 143 | + | dec := json.NewDecoder(bytes.NewReader(b)) | |||
| 144 | + | if t, err := dec.Token(); err != nil || t != json.Delim('{') { | |||
| 145 | + | return nil, errors.New("not a JSON object") | |||
| 146 | + | } | |||
| 147 | + | var out []jsonMember | |||
| 148 | + | for dec.More() { | |||
| 149 | + | kt, err := dec.Token() | |||
| 150 | + | if err != nil { | |||
| 151 | + | return nil, err | |||
| 152 | + | } | |||
| 153 | + | k, ok := kt.(string) | |||
| 154 | + | if !ok { | |||
| 155 | + | return nil, errors.New("not a JSON object") | |||
| 156 | + | } | |||
| 157 | + | var v json.RawMessage | |||
| 158 | + | if err := dec.Decode(&v); err != nil { | |||
| 159 | + | return nil, err | |||
| 160 | + | } | |||
| 161 | + | out = append(out, jsonMember{k, v}) | |||
| 162 | + | } | |||
| 163 | + | if _, err := dec.Token(); err != nil { | |||
| 164 | + | return nil, err | |||
| 165 | + | } | |||
| 166 | + | if _, err := dec.Token(); err != io.EOF { | |||
| 167 | + | return nil, errors.New("data after the JSON object") | |||
| 168 | + | } | |||
| 169 | + | return out, nil | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func encodeObject(ms []jsonMember) (json.RawMessage, error) { | |||
| 173 | + | var b bytes.Buffer | |||
| 174 | + | b.WriteByte('{') | |||
| 175 | + | for i, m := range ms { | |||
| 176 | + | if i > 0 { | |||
| 177 | + | b.WriteByte(',') | |||
| 178 | + | } | |||
| 179 | + | k, err := marshalNoEscape(m.key) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return nil, err | |||
| 182 | + | } | |||
| 183 | + | b.Write(k) | |||
| 184 | + | b.WriteByte(':') | |||
| 185 | + | b.Write(m.val) | |||
| 186 | + | } | |||
| 187 | + | b.WriteByte('}') | |||
| 188 | + | return b.Bytes(), nil | |||
| 189 | + | } | |||
A internal/host/guestforge_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 13 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const publicContexts = `{"github":{"job":"","ref":"refs/heads/main","repository":"octo/hello","repositoryUrl":"git://ermite.bnema.dev/octo/hello.git",` + | |
| 17 | + | `"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev/-/github/api","graphql_url":"https://ermite.bnema.dev/-/github/api/graphql",` + | |
| 18 | + | `"event":{"compare":"https://ermite.bnema.dev/octo/hello/compare/a...b","repository":{"html_url":"https://ermite.bnema.dev/octo/hello"}},` + | |
| 19 | + | `"note":"a & <b>"},"needs":{},"vars":{"U":"https://ermite.bnema.dev/x"}}` | |
| 20 | + | ||
| 21 | + | func TestRewriteServerURLs(t *testing.T) { | |
| 22 | + | got, err := RewriteServerURLs(publicContexts, "http://ermite.bnema.dev:8088") | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | want := strings.NewReplacer( | |
| 27 | + | `"server_url":"https://ermite.bnema.dev"`, `"server_url":"http://ermite.bnema.dev:8088"`, | |
| 28 | + | `"api_url":"https://ermite.bnema.dev/-/github/api"`, `"api_url":"http://ermite.bnema.dev:8088/-/github/api"`, | |
| 29 | + | `"graphql_url":"https://ermite.bnema.dev/-/github/api/graphql"`, `"graphql_url":"http://ermite.bnema.dev:8088/-/github/api/graphql"`, | |
| 30 | + | ).Replace(publicContexts) | |
| 31 | + | // Only the three fields change: same bytes everywhere else (key order, the | |
| 32 | + | // event payload as recorded, other URLs, escaping). | |
| 33 | + | if got != want { | |
| 34 | + | t.Fatalf("got\n%s\nwant\n%s", got, want) | |
| 35 | + | } | |
| 36 | + | if !json.Valid([]byte(got)) { | |
| 37 | + | t.Fatal("invalid JSON") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestRewriteServerURLsMatchesOriginsOnly(t *testing.T) { | |
| 42 | + | const target = "http://forge.internal:8088" | |
| 43 | + | cases := map[string]struct { | |
| 44 | + | in string | |
| 45 | + | same bool | |
| 46 | + | }{ | |
| 47 | + | "other origin is untouched": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://api.example.org/x","graphql_url":"https://ermite.bnema.dev:8443/g"}}`, false}, | |
| 48 | + | "host prefix is not a match": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev.evil.test/x","graphql_url":"https://xermite.bnema.dev/g"}}`, false}, | |
| 49 | + | "no server_url": {`{"github":{"api_url":"https://ermite.bnema.dev/-/github/api"}}`, true}, | |
| 50 | + | "no github": {`{"vars":{"a":"b"}}`, true}, | |
| 51 | + | "empty server_url": {`{"github":{"server_url":"","api_url":"https://ermite.bnema.dev/a"}}`, true}, | |
| 52 | + | "non-string values": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":null,"graphql_url":3}}`, false}, | |
| 53 | + | "already the target": {`{"github":{"server_url":"http://forge.internal:8088","api_url":"http://forge.internal:8088/a"}}`, true}, | |
| 54 | + | "credentials are not origins": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://u:p@ermite.bnema.dev/a"}}`, false}, | |
| 55 | + | } | |
| 56 | + | for name, c := range cases { | |
| 57 | + | got, err := RewriteServerURLs(c.in, target) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatalf("%s: %v", name, err) | |
| 60 | + | } | |
| 61 | + | if c.same && got != c.in { | |
| 62 | + | t.Errorf("%s: changed:\n%s", name, got) | |
| 63 | + | } | |
| 64 | + | var v struct { | |
| 65 | + | GitHub map[string]any `json:"github"` | |
| 66 | + | } | |
| 67 | + | if err := json.Unmarshal([]byte(got), &v); err != nil { | |
| 68 | + | t.Fatalf("%s: %v", name, err) | |
| 69 | + | } | |
| 70 | + | switch name { | |
| 71 | + | case "other origin is untouched", "host prefix is not a match", "credentials are not origins": | |
| 72 | + | if v.GitHub["server_url"] != target { | |
| 73 | + | t.Errorf("%s: server_url %v", name, v.GitHub["server_url"]) | |
| 74 | + | } | |
| 75 | + | if u, _ := v.GitHub["api_url"].(string); strings.HasPrefix(u, target) { | |
| 76 | + | t.Errorf("%s: api_url rewritten: %s", name, u) | |
| 77 | + | } | |
| 78 | + | if u, _ := v.GitHub["graphql_url"].(string); strings.HasPrefix(u, target) { | |
| 79 | + | t.Errorf("%s: graphql_url rewritten: %s", name, u) | |
| 80 | + | } | |
| 81 | + | case "non-string values": | |
| 82 | + | if v.GitHub["server_url"] != target || v.GitHub["api_url"] != nil || v.GitHub["graphql_url"] != float64(3) { | |
| 83 | + | t.Errorf("%s: %v", name, v.GitHub) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestRewriteServerURLsSpellings(t *testing.T) { | |
| 90 | + | // Case of scheme and host and a default port do not change the origin. | |
| 91 | + | in := `{"github":{"server_url":"HTTPS://Ermite.BNEMA.dev:443/","api_url":"https://ermite.bnema.dev/-/github/api?x=1#f"}}` | |
| 92 | + | got, err := RewriteServerURLs(in, "https://forge.test") | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | if got != `{"github":{"server_url":"https://forge.test/","api_url":"https://forge.test/-/github/api?x=1#f"}}` { | |
| 97 | + | t.Fatal(got) | |
| 98 | + | } | |
| 99 | + | // Off: the contexts are returned as received, valid or not. | |
| 100 | + | if got, err := RewriteServerURLs("not json", ""); err != nil || got != "not json" { | |
| 101 | + | t.Fatalf("%q %v", got, err) | |
| 102 | + | } | |
| 103 | + | for _, bad := range []string{"not json", `[1]`, `{"github":[1]}`, `{"github":{}} x`} { | |
| 104 | + | if _, err := RewriteServerURLs(bad, "http://a.test"); err == nil { | |
| 105 | + | t.Errorf("%q accepted", bad) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestOriginOf(t *testing.T) { | |
| 111 | + | for in, want := range map[string]string{ | |
| 112 | + | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | |
| 113 | + | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | |
| 114 | + | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | |
| 115 | + | } { | |
| 116 | + | if got := originOf(in); got != want { | |
| 117 | + | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // The executor receives the rewritten contexts when the access is on, and the | |
| 123 | + | // recorded ones when it is off. | |
| 124 | + | func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) { | |
| 125 | + | for _, on := range []bool{true, false} { | |
| 126 | + | h := newHarness(t, simpleScript("x")) | |
| 127 | + | job := testJob("j1") | |
| 128 | + | job.Contexts = publicContexts | |
| 129 | + | h.srv.jobs = []*ermite.Job{job} | |
| 130 | + | if on { | |
| 131 | + | h.runner.Cfg.GuestForgeOrigin = "http://ermite.bnema.dev:8088" | |
| 132 | + | } | |
| 133 | + | var seen string | |
| 134 | + | inner := h.driver.script | |
| 135 | + | h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |
| 136 | + | seen = run.Job.Contexts | |
| 137 | + | inner(t, c, run, cn) | |
| 138 | + | } | |
| 139 | + | h.run(t) | |
| 140 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |
| 141 | + | if got := strings.Contains(seen, `"server_url":"http://ermite.bnema.dev:8088"`); got != on { | |
| 142 | + | t.Errorf("guest forge %v: contexts %s", on, seen) | |
| 143 | + | } | |
| 144 | + | if !on && seen != publicContexts { | |
| 145 | + | t.Errorf("contexts changed while off: %s", seen) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 13 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const publicContexts = `{"github":{"job":"","ref":"refs/heads/main","repository":"octo/hello","repositoryUrl":"git://ermite.bnema.dev/octo/hello.git",` + | |||
| 17 | + | `"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev/-/github/api","graphql_url":"https://ermite.bnema.dev/-/github/api/graphql",` + | |||
| 18 | + | `"event":{"compare":"https://ermite.bnema.dev/octo/hello/compare/a...b","repository":{"html_url":"https://ermite.bnema.dev/octo/hello"}},` + | |||
| 19 | + | `"note":"a & <b>"},"needs":{},"vars":{"U":"https://ermite.bnema.dev/x"}}` | |||
| 20 | + | ||||
| 21 | + | func TestRewriteServerURLs(t *testing.T) { | |||
| 22 | + | got, err := RewriteServerURLs(publicContexts, "http://ermite.bnema.dev:8088") | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | want := strings.NewReplacer( | |||
| 27 | + | `"server_url":"https://ermite.bnema.dev"`, `"server_url":"http://ermite.bnema.dev:8088"`, | |||
| 28 | + | `"api_url":"https://ermite.bnema.dev/-/github/api"`, `"api_url":"http://ermite.bnema.dev:8088/-/github/api"`, | |||
| 29 | + | `"graphql_url":"https://ermite.bnema.dev/-/github/api/graphql"`, `"graphql_url":"http://ermite.bnema.dev:8088/-/github/api/graphql"`, | |||
| 30 | + | ).Replace(publicContexts) | |||
| 31 | + | // Only the three fields change: same bytes everywhere else (key order, the | |||
| 32 | + | // event payload as recorded, other URLs, escaping). | |||
| 33 | + | if got != want { | |||
| 34 | + | t.Fatalf("got\n%s\nwant\n%s", got, want) | |||
| 35 | + | } | |||
| 36 | + | if !json.Valid([]byte(got)) { | |||
| 37 | + | t.Fatal("invalid JSON") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRewriteServerURLsMatchesOriginsOnly(t *testing.T) { | |||
| 42 | + | const target = "http://forge.internal:8088" | |||
| 43 | + | cases := map[string]struct { | |||
| 44 | + | in string | |||
| 45 | + | same bool | |||
| 46 | + | }{ | |||
| 47 | + | "other origin is untouched": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://api.example.org/x","graphql_url":"https://ermite.bnema.dev:8443/g"}}`, false}, | |||
| 48 | + | "host prefix is not a match": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev.evil.test/x","graphql_url":"https://xermite.bnema.dev/g"}}`, false}, | |||
| 49 | + | "no server_url": {`{"github":{"api_url":"https://ermite.bnema.dev/-/github/api"}}`, true}, | |||
| 50 | + | "no github": {`{"vars":{"a":"b"}}`, true}, | |||
| 51 | + | "empty server_url": {`{"github":{"server_url":"","api_url":"https://ermite.bnema.dev/a"}}`, true}, | |||
| 52 | + | "non-string values": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":null,"graphql_url":3}}`, false}, | |||
| 53 | + | "already the target": {`{"github":{"server_url":"http://forge.internal:8088","api_url":"http://forge.internal:8088/a"}}`, true}, | |||
| 54 | + | "credentials are not origins": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://u:p@ermite.bnema.dev/a"}}`, false}, | |||
| 55 | + | } | |||
| 56 | + | for name, c := range cases { | |||
| 57 | + | got, err := RewriteServerURLs(c.in, target) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatalf("%s: %v", name, err) | |||
| 60 | + | } | |||
| 61 | + | if c.same && got != c.in { | |||
| 62 | + | t.Errorf("%s: changed:\n%s", name, got) | |||
| 63 | + | } | |||
| 64 | + | var v struct { | |||
| 65 | + | GitHub map[string]any `json:"github"` | |||
| 66 | + | } | |||
| 67 | + | if err := json.Unmarshal([]byte(got), &v); err != nil { | |||
| 68 | + | t.Fatalf("%s: %v", name, err) | |||
| 69 | + | } | |||
| 70 | + | switch name { | |||
| 71 | + | case "other origin is untouched", "host prefix is not a match", "credentials are not origins": | |||
| 72 | + | if v.GitHub["server_url"] != target { | |||
| 73 | + | t.Errorf("%s: server_url %v", name, v.GitHub["server_url"]) | |||
| 74 | + | } | |||
| 75 | + | if u, _ := v.GitHub["api_url"].(string); strings.HasPrefix(u, target) { | |||
| 76 | + | t.Errorf("%s: api_url rewritten: %s", name, u) | |||
| 77 | + | } | |||
| 78 | + | if u, _ := v.GitHub["graphql_url"].(string); strings.HasPrefix(u, target) { | |||
| 79 | + | t.Errorf("%s: graphql_url rewritten: %s", name, u) | |||
| 80 | + | } | |||
| 81 | + | case "non-string values": | |||
| 82 | + | if v.GitHub["server_url"] != target || v.GitHub["api_url"] != nil || v.GitHub["graphql_url"] != float64(3) { | |||
| 83 | + | t.Errorf("%s: %v", name, v.GitHub) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestRewriteServerURLsSpellings(t *testing.T) { | |||
| 90 | + | // Case of scheme and host and a default port do not change the origin. | |||
| 91 | + | in := `{"github":{"server_url":"HTTPS://Ermite.BNEMA.dev:443/","api_url":"https://ermite.bnema.dev/-/github/api?x=1#f"}}` | |||
| 92 | + | got, err := RewriteServerURLs(in, "https://forge.test") | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | if got != `{"github":{"server_url":"https://forge.test/","api_url":"https://forge.test/-/github/api?x=1#f"}}` { | |||
| 97 | + | t.Fatal(got) | |||
| 98 | + | } | |||
| 99 | + | // Off: the contexts are returned as received, valid or not. | |||
| 100 | + | if got, err := RewriteServerURLs("not json", ""); err != nil || got != "not json" { | |||
| 101 | + | t.Fatalf("%q %v", got, err) | |||
| 102 | + | } | |||
| 103 | + | for _, bad := range []string{"not json", `[1]`, `{"github":[1]}`, `{"github":{}} x`} { | |||
| 104 | + | if _, err := RewriteServerURLs(bad, "http://a.test"); err == nil { | |||
| 105 | + | t.Errorf("%q accepted", bad) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestOriginOf(t *testing.T) { | |||
| 111 | + | for in, want := range map[string]string{ | |||
| 112 | + | "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test", | |||
| 113 | + | "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test", | |||
| 114 | + | "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "", | |||
| 115 | + | } { | |||
| 116 | + | if got := originOf(in); got != want { | |||
| 117 | + | t.Errorf("originOf(%q) = %q, want %q", in, got, want) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // The executor receives the rewritten contexts when the access is on, and the | |||
| 123 | + | // recorded ones when it is off. | |||
| 124 | + | func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) { | |||
| 125 | + | for _, on := range []bool{true, false} { | |||
| 126 | + | h := newHarness(t, simpleScript("x")) | |||
| 127 | + | job := testJob("j1") | |||
| 128 | + | job.Contexts = publicContexts | |||
| 129 | + | h.srv.jobs = []*ermite.Job{job} | |||
| 130 | + | if on { | |||
| 131 | + | h.runner.Cfg.GuestForgeOrigin = "http://ermite.bnema.dev:8088" | |||
| 132 | + | } | |||
| 133 | + | var seen string | |||
| 134 | + | inner := h.driver.script | |||
| 135 | + | h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |||
| 136 | + | seen = run.Job.Contexts | |||
| 137 | + | inner(t, c, run, cn) | |||
| 138 | + | } | |||
| 139 | + | h.run(t) | |||
| 140 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 141 | + | if got := strings.Contains(seen, `"server_url":"http://ermite.bnema.dev:8088"`); got != on { | |||
| 142 | + | t.Errorf("guest forge %v: contexts %s", on, seen) | |||
| 143 | + | } | |||
| 144 | + | if !on && seen != publicContexts { | |||
| 145 | + | t.Errorf("contexts changed while off: %s", seen) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | } | |||
M internal/host/job.go +10 -1
| @@ -67,6 +67,9 @@ type jobRun struct { | |||
| 67 | 67 | maxStarted int32 | |
| 68 | 68 | lastLogStep int32 | |
| 69 | 69 | violation string | |
| 70 | + | // contexts is Job.Contexts as handed to the executor: with the server URLs | |
| 71 | + | // pointed at the runner's URL when guest forge access is on. | |
| 72 | + | contexts string | |
| 70 | 73 | // hard stops the sandbox at once; set before the session starts. | |
| 71 | 74 | hard func() | |
| 72 | 75 | result *execpb.JobResult | |
| @@ -562,11 +565,17 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL | |||
| 562 | 565 | ||
| 563 | 566 | // execute starts the sandbox, runs the job in it and returns how it ended. | |
| 564 | 567 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | |
| 568 | + | var err error | |
| 569 | + | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | |
| 570 | + | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 571 | + | return failure() | |
| 572 | + | } | |
| 565 | 573 | sb, err := rt.Driver.New() | |
| 566 | 574 | if err != nil { | |
| 567 | 575 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | |
| 568 | 576 | return failure() | |
| 569 | 577 | } | |
| 578 | + | ||
| 570 | 579 | // Stop runs to completion even when the job context is done. | |
| 571 | 580 | defer j.stopSandbox(ctx, rt, sb) | |
| 572 | 581 | ||
| @@ -632,7 +641,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo | |||
| 632 | 641 | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |
| 633 | 642 | run := &execpb.Run{Job: &execpb.JobSpec{ | |
| 634 | 643 | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | |
| 635 | - | Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | |
| 644 | + | Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | |
| 636 | 645 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | |
| 637 | 646 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | |
| 638 | 647 | }} | |
| @@ -67,6 +67,9 @@ type jobRun struct { | |||||
| 67 | maxStarted int32 | 67 | maxStarted int32 | ||
| 68 | lastLogStep int32 | 68 | lastLogStep int32 | ||
| 69 | violation string | 69 | violation string | ||
| 70 | + | // contexts is Job.Contexts as handed to the executor: with the server URLs | |||
| 71 | + | // pointed at the runner's URL when guest forge access is on. | |||
| 72 | + | contexts string | |||
| 70 | // hard stops the sandbox at once; set before the session starts. | 73 | // hard stops the sandbox at once; set before the session starts. | ||
| 71 | hard func() | 74 | hard func() | ||
| 72 | result *execpb.JobResult | 75 | result *execpb.JobResult | ||
| @@ -562,11 +565,17 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL | |||||
| 562 | 565 | ||||
| 563 | // execute starts the sandbox, runs the job in it and returns how it ended. | 566 | // execute starts the sandbox, runs the job in it and returns how it ended. | ||
| 564 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | 567 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | ||
| 568 | + | var err error | |||
| 569 | + | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | |||
| 570 | + | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |||
| 571 | + | return failure() | |||
| 572 | + | } | |||
| 565 | sb, err := rt.Driver.New() | 573 | sb, err := rt.Driver.New() | ||
| 566 | if err != nil { | 574 | if err != nil { | ||
| 567 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | 575 | j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err)) | ||
| 568 | return failure() | 576 | return failure() | ||
| 569 | } | 577 | } | ||
| 578 | + | ||||
| 570 | // Stop runs to completion even when the job context is done. | 579 | // Stop runs to completion even when the job context is done. | ||
| 571 | defer j.stopSandbox(ctx, rt, sb) | 580 | defer j.stopSandbox(ctx, rt, sb) | ||
| 572 | 581 | ||||
| @@ -632,7 +641,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo | |||||
| 632 | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | 641 | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | ||
| 633 | run := &execpb.Run{Job: &execpb.JobSpec{ | 642 | run := &execpb.Run{Job: &execpb.JobSpec{ | ||
| 634 | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | 643 | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | ||
| 635 | - | Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | 644 | + | Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), |
| 636 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | 645 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | ||
| 637 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | 646 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | ||
| 638 | }} | 647 | }} | ||
M internal/sandbox/config.go +14 -0
| @@ -108,6 +108,13 @@ type Config struct { | |||
| 108 | 108 | DenyPrivileged bool | |
| 109 | 109 | // LockFile is the host-wide single-instance lock. | |
| 110 | 110 | LockFile string | |
| 111 | + | // GuestForgeEnabled is ERMITE_RUNNER_GUEST_FORGE=1. The endpoint itself | |
| 112 | + | // comes from the server URL, which is not a sandbox setting: the caller | |
| 113 | + | // sets GuestForge (ResolveGuestForge) before building the drivers. | |
| 114 | + | GuestForgeEnabled bool | |
| 115 | + | // GuestForge, when not nil, is the one host endpoint that every sandbox may | |
| 116 | + | // reach, and the name the DNS forwarder answers with the sandbox's gateway. | |
| 117 | + | GuestForge *GuestForge | |
| 111 | 118 | // Rate limits of micro-VMs; zero disables. | |
| 112 | 119 | DiskBandwidthMiB, DiskIOPS, NetMbit int | |
| 113 | 120 | } | |
| @@ -189,6 +196,13 @@ func LoadConfig(getenv func(string) string) (Config, error) { | |||
| 189 | 196 | default: | |
| 190 | 197 | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged)) | |
| 191 | 198 | } | |
| 199 | + | switch strings.ToLower(strings.TrimSpace(getenv(EnvGuestForge))) { | |
| 200 | + | case "", "0", "false", "no": | |
| 201 | + | case "1", "true", "yes": | |
| 202 | + | c.GuestForgeEnabled = true | |
| 203 | + | default: | |
| 204 | + | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvGuestForge, getenv(EnvGuestForge)) | |
| 205 | + | } | |
| 192 | 206 | if v := getenv(EnvLockFile); v != "" { | |
| 193 | 207 | if !filepath.IsAbs(v) { | |
| 194 | 208 | return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v) | |
| @@ -108,6 +108,13 @@ type Config struct { | |||||
| 108 | DenyPrivileged bool | 108 | DenyPrivileged bool | ||
| 109 | // LockFile is the host-wide single-instance lock. | 109 | // LockFile is the host-wide single-instance lock. | ||
| 110 | LockFile string | 110 | LockFile string | ||
| 111 | + | // GuestForgeEnabled is ERMITE_RUNNER_GUEST_FORGE=1. The endpoint itself | |||
| 112 | + | // comes from the server URL, which is not a sandbox setting: the caller | |||
| 113 | + | // sets GuestForge (ResolveGuestForge) before building the drivers. | |||
| 114 | + | GuestForgeEnabled bool | |||
| 115 | + | // GuestForge, when not nil, is the one host endpoint that every sandbox may | |||
| 116 | + | // reach, and the name the DNS forwarder answers with the sandbox's gateway. | |||
| 117 | + | GuestForge *GuestForge | |||
| 111 | // Rate limits of micro-VMs; zero disables. | 118 | // Rate limits of micro-VMs; zero disables. | ||
| 112 | DiskBandwidthMiB, DiskIOPS, NetMbit int | 119 | DiskBandwidthMiB, DiskIOPS, NetMbit int | ||
| 113 | } | 120 | } | ||
| @@ -189,6 +196,13 @@ func LoadConfig(getenv func(string) string) (Config, error) { | |||||
| 189 | default: | 196 | default: | ||
| 190 | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged)) | 197 | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged)) | ||
| 191 | } | 198 | } | ||
| 199 | + | switch strings.ToLower(strings.TrimSpace(getenv(EnvGuestForge))) { | |||
| 200 | + | case "", "0", "false", "no": | |||
| 201 | + | case "1", "true", "yes": | |||
| 202 | + | c.GuestForgeEnabled = true | |||
| 203 | + | default: | |||
| 204 | + | return c, fmt.Errorf("%s: %q is not 0 or 1", EnvGuestForge, getenv(EnvGuestForge)) | |||
| 205 | + | } | |||
| 192 | if v := getenv(EnvLockFile); v != "" { | 206 | if v := getenv(EnvLockFile); v != "" { | ||
| 193 | if !filepath.IsAbs(v) { | 207 | if !filepath.IsAbs(v) { | ||
| 194 | return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v) | 208 | return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v) | ||
M internal/sandbox/container/container_test.go +65 -2
| @@ -1014,10 +1014,73 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||
| 1014 | 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | |
| 1015 | 1015 | // address; Docker keeps a normal bind. | |
| 1016 | 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | |
| 1017 | - | if newNet(nil, 0, false).DNS.FreeBind { | |
| 1017 | + | if newNet(nil, 0, false, "").DNS.FreeBind { | |
| 1018 | 1018 | t.Fatal("Docker must keep a normal bind") | |
| 1019 | 1019 | } | |
| 1020 | - | if !newNet(nil, 0, true).DNS.FreeBind { | |
| 1020 | + | if !newNet(nil, 0, true, "").DNS.FreeBind { | |
| 1021 | 1021 | t.Fatal("podman needs the listener before the bridge address") | |
| 1022 | 1022 | } | |
| 1023 | 1023 | } | |
| 1024 | + | ||
| 1025 | + | // Guest forge access: the exception reaches the policy of every guest, and the | |
| 1026 | + | // warning naming the origin is logged at the driver start and at every job. | |
| 1027 | + | func TestGuestForgeIsAttachedAndWarnedAtEveryJob(t *testing.T) { | |
| 1028 | + | e := newCEnv(t) | |
| 1029 | + | var err error | |
| 1030 | + | if e.cfg.GuestForge, err = sandbox.ParseGuestForge("http://ermite.bnema.dev:8088", true); err != nil { | |
| 1031 | + | t.Fatal(err) | |
| 1032 | + | } | |
| 1033 | + | var buf bytes.Buffer | |
| 1034 | + | ctx := logger(&buf) | |
| 1035 | + | d, err := New(ctx, e.cfg, e.opts()) | |
| 1036 | + | if err != nil { | |
| 1037 | + | t.Fatal(err) | |
| 1038 | + | } | |
| 1039 | + | defer func() { _ = d.Close() }() | |
| 1040 | + | msg := "guest forge access: sandboxes may reach http://ermite.bnema.dev:8088 on the host" | |
| 1041 | + | if n := strings.Count(buf.String(), msg); n != 1 { | |
| 1042 | + | t.Fatalf("one warning at driver start, got %d", n) | |
| 1043 | + | } | |
| 1044 | + | for i := range 2 { | |
| 1045 | + | sb, _ := d.New() | |
| 1046 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: fmt.Sprint("job", i)}) | |
| 1047 | + | if err != nil { | |
| 1048 | + | t.Fatal(err) | |
| 1049 | + | } | |
| 1050 | + | for _, g := range e.plumb.Attached { | |
| 1051 | + | if g.ForgePort != 8088 { | |
| 1052 | + | t.Errorf("guest attached with ForgePort %d", g.ForgePort) | |
| 1053 | + | } | |
| 1054 | + | } | |
| 1055 | + | stub(t, conn) | |
| 1056 | + | _ = sb.Stop(ctx) | |
| 1057 | + | } | |
| 1058 | + | if n := strings.Count(buf.String(), msg); n != 3 { | |
| 1059 | + | t.Fatalf("the warning must be logged at every job start (1 + 2), got %d", n) | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | func TestNoGuestForgeByDefault(t *testing.T) { | |
| 1064 | + | e := newCEnv(t) | |
| 1065 | + | var buf bytes.Buffer | |
| 1066 | + | ctx := logger(&buf) | |
| 1067 | + | d := e.driver(t) | |
| 1068 | + | sb, _ := d.New() | |
| 1069 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "j"}) | |
| 1070 | + | if err != nil { | |
| 1071 | + | t.Fatal(err) | |
| 1072 | + | } | |
| 1073 | + | for _, g := range e.plumb.Attached { | |
| 1074 | + | if g.ForgePort != 0 { | |
| 1075 | + | t.Errorf("ForgePort %d without the setting", g.ForgePort) | |
| 1076 | + | } | |
| 1077 | + | } | |
| 1078 | + | stub(t, conn) | |
| 1079 | + | _ = sb.Stop(ctx) | |
| 1080 | + | if strings.Contains(buf.String(), "guest forge") { | |
| 1081 | + | t.Fatal("a guest forge warning without the setting") | |
| 1082 | + | } | |
| 1083 | + | if n := newNet(nil, 0, false, "ermite.example.org").DNS.Override; n != "ermite.example.org" { | |
| 1084 | + | t.Fatalf("override %q", n) | |
| 1085 | + | } | |
| 1086 | + | } | |
| @@ -1014,10 +1014,73 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||||
| 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | ||
| 1015 | // address; Docker keeps a normal bind. | 1015 | // address; Docker keeps a normal bind. | ||
| 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | ||
| 1017 | - | if newNet(nil, 0, false).DNS.FreeBind { | 1017 | + | if newNet(nil, 0, false, "").DNS.FreeBind { |
| 1018 | t.Fatal("Docker must keep a normal bind") | 1018 | t.Fatal("Docker must keep a normal bind") | ||
| 1019 | } | 1019 | } | ||
| 1020 | - | if !newNet(nil, 0, true).DNS.FreeBind { | 1020 | + | if !newNet(nil, 0, true, "").DNS.FreeBind { |
| 1021 | t.Fatal("podman needs the listener before the bridge address") | 1021 | t.Fatal("podman needs the listener before the bridge address") | ||
| 1022 | } | 1022 | } | ||
| 1023 | } | 1023 | } | ||
| 1024 | + | ||||
| 1025 | + | // Guest forge access: the exception reaches the policy of every guest, and the | |||
| 1026 | + | // warning naming the origin is logged at the driver start and at every job. | |||
| 1027 | + | func TestGuestForgeIsAttachedAndWarnedAtEveryJob(t *testing.T) { | |||
| 1028 | + | e := newCEnv(t) | |||
| 1029 | + | var err error | |||
| 1030 | + | if e.cfg.GuestForge, err = sandbox.ParseGuestForge("http://ermite.bnema.dev:8088", true); err != nil { | |||
| 1031 | + | t.Fatal(err) | |||
| 1032 | + | } | |||
| 1033 | + | var buf bytes.Buffer | |||
| 1034 | + | ctx := logger(&buf) | |||
| 1035 | + | d, err := New(ctx, e.cfg, e.opts()) | |||
| 1036 | + | if err != nil { | |||
| 1037 | + | t.Fatal(err) | |||
| 1038 | + | } | |||
| 1039 | + | defer func() { _ = d.Close() }() | |||
| 1040 | + | msg := "guest forge access: sandboxes may reach http://ermite.bnema.dev:8088 on the host" | |||
| 1041 | + | if n := strings.Count(buf.String(), msg); n != 1 { | |||
| 1042 | + | t.Fatalf("one warning at driver start, got %d", n) | |||
| 1043 | + | } | |||
| 1044 | + | for i := range 2 { | |||
| 1045 | + | sb, _ := d.New() | |||
| 1046 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: fmt.Sprint("job", i)}) | |||
| 1047 | + | if err != nil { | |||
| 1048 | + | t.Fatal(err) | |||
| 1049 | + | } | |||
| 1050 | + | for _, g := range e.plumb.Attached { | |||
| 1051 | + | if g.ForgePort != 8088 { | |||
| 1052 | + | t.Errorf("guest attached with ForgePort %d", g.ForgePort) | |||
| 1053 | + | } | |||
| 1054 | + | } | |||
| 1055 | + | stub(t, conn) | |||
| 1056 | + | _ = sb.Stop(ctx) | |||
| 1057 | + | } | |||
| 1058 | + | if n := strings.Count(buf.String(), msg); n != 3 { | |||
| 1059 | + | t.Fatalf("the warning must be logged at every job start (1 + 2), got %d", n) | |||
| 1060 | + | } | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func TestNoGuestForgeByDefault(t *testing.T) { | |||
| 1064 | + | e := newCEnv(t) | |||
| 1065 | + | var buf bytes.Buffer | |||
| 1066 | + | ctx := logger(&buf) | |||
| 1067 | + | d := e.driver(t) | |||
| 1068 | + | sb, _ := d.New() | |||
| 1069 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "j"}) | |||
| 1070 | + | if err != nil { | |||
| 1071 | + | t.Fatal(err) | |||
| 1072 | + | } | |||
| 1073 | + | for _, g := range e.plumb.Attached { | |||
| 1074 | + | if g.ForgePort != 0 { | |||
| 1075 | + | t.Errorf("ForgePort %d without the setting", g.ForgePort) | |||
| 1076 | + | } | |||
| 1077 | + | } | |||
| 1078 | + | stub(t, conn) | |||
| 1079 | + | _ = sb.Stop(ctx) | |||
| 1080 | + | if strings.Contains(buf.String(), "guest forge") { | |||
| 1081 | + | t.Fatal("a guest forge warning without the setting") | |||
| 1082 | + | } | |||
| 1083 | + | if n := newNet(nil, 0, false, "ermite.example.org").DNS.Override; n != "ermite.example.org" { | |||
| 1084 | + | t.Fatalf("override %q", n) | |||
| 1085 | + | } | |||
| 1086 | + | } | |||
M internal/sandbox/container/driver.go +10 -3
| @@ -163,20 +163,24 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 163 | 163 | } | |
| 164 | 164 | d.net = opt.Net | |
| 165 | 165 | if d.net == nil { | |
| 166 | - | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman) | |
| 166 | + | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName()) | |
| 167 | 167 | } | |
| 168 | 168 | if err := d.net.EnableForwarding(); err != nil { | |
| 169 | 169 | return fail(err) | |
| 170 | 170 | } | |
| 171 | + | if cfg.GuestForge != nil { | |
| 172 | + | log.Warn().Msg(cfg.GuestForge.Warning()) | |
| 173 | + | } | |
| 171 | 174 | return d, nil | |
| 172 | 175 | } | |
| 173 | 176 | ||
| 174 | 177 | // newNet builds the host network plumbing. Only podman creates the bridge | |
| 175 | 178 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | |
| 176 | 179 | // before the address; on Docker a missing gateway address stays a startup error. | |
| 177 | - | func newNet(run cmdrun.Runner, dnsPort int, podman bool) *netsetup.Net { | |
| 180 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost string) *netsetup.Net { | |
| 178 | 181 | n := netsetup.New(run, dnsPort, "") | |
| 179 | 182 | n.DNS.FreeBind = podman | |
| 183 | + | n.DNS.Override = forgeHost | |
| 180 | 184 | return n | |
| 181 | 185 | } | |
| 182 | 186 | ||
| @@ -302,6 +306,9 @@ func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (san | |||
| 302 | 306 | // operator's logs must say what kind of isolation this job got. | |
| 303 | 307 | log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(privilegedWarning) | |
| 304 | 308 | } | |
| 309 | + | if d.cfg.GuestForge != nil { | |
| 310 | + | log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(d.cfg.GuestForge.Warning()) | |
| 311 | + | } | |
| 305 | 312 | conn, err := s.run(bootCtx, spec) | |
| 306 | 313 | if err != nil { | |
| 307 | 314 | var tail string | |
| @@ -421,7 +428,7 @@ func (s *containerSandbox) runNetwork(ctx context.Context, netw netpolicy.Net, l | |||
| 421 | 428 | } | |
| 422 | 429 | s.netID = netID | |
| 423 | 430 | s.attached = true | |
| 424 | - | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest}) | |
| 431 | + | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest, ForgePort: d.cfg.GuestForge.TCPPort()}) | |
| 425 | 432 | } | |
| 426 | 433 | ||
| 427 | 434 | // runContainer creates and starts the container. | |
| @@ -163,20 +163,24 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 163 | } | 163 | } | ||
| 164 | d.net = opt.Net | 164 | d.net = opt.Net | ||
| 165 | if d.net == nil { | 165 | if d.net == nil { | ||
| 166 | - | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman) | 166 | + | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName()) |
| 167 | } | 167 | } | ||
| 168 | if err := d.net.EnableForwarding(); err != nil { | 168 | if err := d.net.EnableForwarding(); err != nil { | ||
| 169 | return fail(err) | 169 | return fail(err) | ||
| 170 | } | 170 | } | ||
| 171 | + | if cfg.GuestForge != nil { | |||
| 172 | + | log.Warn().Msg(cfg.GuestForge.Warning()) | |||
| 173 | + | } | |||
| 171 | return d, nil | 174 | return d, nil | ||
| 172 | } | 175 | } | ||
| 173 | 176 | ||||
| 174 | // newNet builds the host network plumbing. Only podman creates the bridge | 177 | // newNet builds the host network plumbing. Only podman creates the bridge | ||
| 175 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | 178 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | ||
| 176 | // before the address; on Docker a missing gateway address stays a startup error. | 179 | // before the address; on Docker a missing gateway address stays a startup error. | ||
| 177 | - | func newNet(run cmdrun.Runner, dnsPort int, podman bool) *netsetup.Net { | 180 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost string) *netsetup.Net { |
| 178 | n := netsetup.New(run, dnsPort, "") | 181 | n := netsetup.New(run, dnsPort, "") | ||
| 179 | n.DNS.FreeBind = podman | 182 | n.DNS.FreeBind = podman | ||
| 183 | + | n.DNS.Override = forgeHost | |||
| 180 | return n | 184 | return n | ||
| 181 | } | 185 | } | ||
| 182 | 186 | ||||
| @@ -302,6 +306,9 @@ func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (san | |||||
| 302 | // operator's logs must say what kind of isolation this job got. | 306 | // operator's logs must say what kind of isolation this job got. | ||
| 303 | log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(privilegedWarning) | 307 | log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(privilegedWarning) | ||
| 304 | } | 308 | } | ||
| 309 | + | if d.cfg.GuestForge != nil { | |||
| 310 | + | log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(d.cfg.GuestForge.Warning()) | |||
| 311 | + | } | |||
| 305 | conn, err := s.run(bootCtx, spec) | 312 | conn, err := s.run(bootCtx, spec) | ||
| 306 | if err != nil { | 313 | if err != nil { | ||
| 307 | var tail string | 314 | var tail string | ||
| @@ -421,7 +428,7 @@ func (s *containerSandbox) runNetwork(ctx context.Context, netw netpolicy.Net, l | |||||
| 421 | } | 428 | } | ||
| 422 | s.netID = netID | 429 | s.netID = netID | ||
| 423 | s.attached = true | 430 | s.attached = true | ||
| 424 | - | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest}) | 431 | + | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.iface, Gateway: netw.Gateway, Addr: netw.Guest, ForgePort: d.cfg.GuestForge.TCPPort()}) |
| 425 | } | 432 | } | ||
| 426 | 433 | ||||
| 427 | // runContainer creates and starts the container. | 434 | // runContainer creates and starts the container. | ||
A internal/sandbox/guestforge.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package sandbox | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "net/netip" | |
| 10 | + | "net/url" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // EnvGuestForge is ERMITE_RUNNER_GUEST_FORGE=1: sandboxes reach the forge the | |
| 17 | + | // way the runner does, that is at the host and port of ERMITE_RUNNER_URL, on | |
| 18 | + | // the host. For staging hosts where the forge is not reachable from the | |
| 19 | + | // internet side of the NAT (see the README). Off by default. | |
| 20 | + | const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE" | |
| 21 | + | ||
| 22 | + | // GuestForge is the one forge endpoint that sandboxes may reach on the host. | |
| 23 | + | // It is derived from the server URL by ParseGuestForge. | |
| 24 | + | type GuestForge struct { | |
| 25 | + | // Host is the lower-case DNS name of the forge, as in the URL. The DNS | |
| 26 | + | // forwarder answers it with the gateway address of each sandbox. | |
| 27 | + | Host string | |
| 28 | + | // Port is the TCP port: the only host port a sandbox may reach. | |
| 29 | + | Port uint16 | |
| 30 | + | // Origin is scheme://host[:port] of the URL: the jobs' server URLs are | |
| 31 | + | // rewritten to it. | |
| 32 | + | Origin string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Warning is the line logged at start and for each job. | |
| 36 | + | func (g *GuestForge) Warning() string { | |
| 37 | + | return "guest forge access: sandboxes may reach " + g.Origin + " on the host" | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // DNSName is the overridden name, "" when the access is off. Safe on nil. | |
| 41 | + | func (g *GuestForge) DNSName() string { | |
| 42 | + | if g == nil { | |
| 43 | + | return "" | |
| 44 | + | } | |
| 45 | + | return g.Host | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // TCPPort is the allowed port, 0 when the access is off. Safe on nil. | |
| 49 | + | func (g *GuestForge) TCPPort() uint16 { | |
| 50 | + | if g == nil { | |
| 51 | + | return 0 | |
| 52 | + | } | |
| 53 | + | return g.Port | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ParseGuestForge derives the endpoint from the server URL. The URL must have | |
| 57 | + | // a DNS host name (an IP literal cannot be redirected by the DNS forwarder), | |
| 58 | + | // no path, query or credentials, and the port defaults from the scheme (443, | |
| 59 | + | // 80). A plain http URL is accepted only with insecureHTTP (ERMITE_RUNNER_ | |
| 60 | + | // INSECURE_HTTP=1): the job tokens then cross the host in clear, and that must | |
| 61 | + | // be asked for explicitly even for localhost. | |
| 62 | + | func ParseGuestForge(raw string, insecureHTTP bool) (*GuestForge, error) { | |
| 63 | + | u, err := url.Parse(raw) | |
| 64 | + | if err != nil || u.Host == "" { | |
| 65 | + | return nil, fmt.Errorf("%s: the server URL %q is not a valid URL", EnvGuestForge, raw) | |
| 66 | + | } | |
| 67 | + | if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { | |
| 68 | + | return nil, fmt.Errorf("%s: the server URL must be a bare origin (scheme://host[:port]): the jobs' URLs are rewritten to the origin alone", EnvGuestForge) | |
| 69 | + | } | |
| 70 | + | var def uint16 | |
| 71 | + | switch u.Scheme { | |
| 72 | + | case "https": | |
| 73 | + | def = 443 | |
| 74 | + | case "http": | |
| 75 | + | def = 80 | |
| 76 | + | if !insecureHTTP { | |
| 77 | + | return nil, fmt.Errorf("%s: the server URL is plain http: set ERMITE_RUNNER_INSECURE_HTTP=1 to accept that sandboxes then send their tokens in clear", EnvGuestForge) | |
| 78 | + | } | |
| 79 | + | default: | |
| 80 | + | return nil, fmt.Errorf("%s: the server URL must be http or https", EnvGuestForge) | |
| 81 | + | } | |
| 82 | + | host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) | |
| 83 | + | if err := CheckForgeHostname(host); err != nil { | |
| 84 | + | return nil, fmt.Errorf("%s: %w", EnvGuestForge, err) | |
| 85 | + | } | |
| 86 | + | port := def | |
| 87 | + | if p := u.Port(); p != "" { | |
| 88 | + | n, err := strconv.Atoi(p) | |
| 89 | + | if err != nil || n < 1 || n > 65535 { | |
| 90 | + | return nil, fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForge, p) | |
| 91 | + | } | |
| 92 | + | port = uint16(n) | |
| 93 | + | } | |
| 94 | + | origin := u.Scheme + "://" + host | |
| 95 | + | if u.Port() != "" { | |
| 96 | + | origin += ":" + u.Port() | |
| 97 | + | } | |
| 98 | + | return &GuestForge{Host: host, Port: port, Origin: origin}, nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // CheckForgeHostname accepts one DNS host name: dot separated labels of ASCII | |
| 102 | + | // letters, digits and hyphens (not at either end), at most 63 bytes each and | |
| 103 | + | // 253 in all. IP literals, wildcards, underscores, IDNs and empty labels are | |
| 104 | + | // refused. | |
| 105 | + | func CheckForgeHostname(h string) error { | |
| 106 | + | if h == "" || len(h) > 253 { | |
| 107 | + | return errors.New("the host name is empty or longer than 253 bytes") | |
| 108 | + | } | |
| 109 | + | if _, err := netip.ParseAddr(h); err == nil { | |
| 110 | + | return fmt.Errorf("%q is an IP address: a host name is required (sandboxes are pointed at the forge by DNS)", h) | |
| 111 | + | } | |
| 112 | + | labels := strings.Split(h, ".") | |
| 113 | + | allNumeric := true | |
| 114 | + | for _, l := range labels { | |
| 115 | + | if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' { | |
| 116 | + | return fmt.Errorf("%q is not a valid host name", h) | |
| 117 | + | } | |
| 118 | + | numeric := true | |
| 119 | + | for _, r := range l { | |
| 120 | + | ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' | |
| 121 | + | if !ok { | |
| 122 | + | return fmt.Errorf("%q is not a valid host name (wildcards, underscores and non-ASCII are refused)", h) | |
| 123 | + | } | |
| 124 | + | if r < '0' || r > '9' { | |
| 125 | + | numeric = false | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | allNumeric = allNumeric && numeric | |
| 129 | + | } | |
| 130 | + | if allNumeric { | |
| 131 | + | // "10.1" or "2130706433": legacy spellings of an address. | |
| 132 | + | return fmt.Errorf("%q looks like an IP address: a host name is required", h) | |
| 133 | + | } | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // ResolveGuestForge is ParseGuestForge plus a check that the host name | |
| 138 | + | // resolves on the host (lookup is net.DefaultResolver.LookupHost in | |
| 139 | + | // production): the runner reaches the forge by that name, so a name that | |
| 140 | + | // resolves to nothing is a configuration error to report at start. | |
| 141 | + | func ResolveGuestForge(ctx context.Context, raw string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | |
| 142 | + | g, err := ParseGuestForge(raw, insecureHTTP) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil, err | |
| 145 | + | } | |
| 146 | + | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 147 | + | defer cancel() | |
| 148 | + | addrs, err := lookup(ctx, g.Host) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, g.Host, err) | |
| 151 | + | } | |
| 152 | + | if len(addrs) == 0 { | |
| 153 | + | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, g.Host) | |
| 154 | + | } | |
| 155 | + | return g, nil | |
| 156 | + | } | |
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package sandbox | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "net/netip" | |||
| 10 | + | "net/url" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // EnvGuestForge is ERMITE_RUNNER_GUEST_FORGE=1: sandboxes reach the forge the | |||
| 17 | + | // way the runner does, that is at the host and port of ERMITE_RUNNER_URL, on | |||
| 18 | + | // the host. For staging hosts where the forge is not reachable from the | |||
| 19 | + | // internet side of the NAT (see the README). Off by default. | |||
| 20 | + | const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE" | |||
| 21 | + | ||||
| 22 | + | // GuestForge is the one forge endpoint that sandboxes may reach on the host. | |||
| 23 | + | // It is derived from the server URL by ParseGuestForge. | |||
| 24 | + | type GuestForge struct { | |||
| 25 | + | // Host is the lower-case DNS name of the forge, as in the URL. The DNS | |||
| 26 | + | // forwarder answers it with the gateway address of each sandbox. | |||
| 27 | + | Host string | |||
| 28 | + | // Port is the TCP port: the only host port a sandbox may reach. | |||
| 29 | + | Port uint16 | |||
| 30 | + | // Origin is scheme://host[:port] of the URL: the jobs' server URLs are | |||
| 31 | + | // rewritten to it. | |||
| 32 | + | Origin string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Warning is the line logged at start and for each job. | |||
| 36 | + | func (g *GuestForge) Warning() string { | |||
| 37 | + | return "guest forge access: sandboxes may reach " + g.Origin + " on the host" | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // DNSName is the overridden name, "" when the access is off. Safe on nil. | |||
| 41 | + | func (g *GuestForge) DNSName() string { | |||
| 42 | + | if g == nil { | |||
| 43 | + | return "" | |||
| 44 | + | } | |||
| 45 | + | return g.Host | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // TCPPort is the allowed port, 0 when the access is off. Safe on nil. | |||
| 49 | + | func (g *GuestForge) TCPPort() uint16 { | |||
| 50 | + | if g == nil { | |||
| 51 | + | return 0 | |||
| 52 | + | } | |||
| 53 | + | return g.Port | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // ParseGuestForge derives the endpoint from the server URL. The URL must have | |||
| 57 | + | // a DNS host name (an IP literal cannot be redirected by the DNS forwarder), | |||
| 58 | + | // no path, query or credentials, and the port defaults from the scheme (443, | |||
| 59 | + | // 80). A plain http URL is accepted only with insecureHTTP (ERMITE_RUNNER_ | |||
| 60 | + | // INSECURE_HTTP=1): the job tokens then cross the host in clear, and that must | |||
| 61 | + | // be asked for explicitly even for localhost. | |||
| 62 | + | func ParseGuestForge(raw string, insecureHTTP bool) (*GuestForge, error) { | |||
| 63 | + | u, err := url.Parse(raw) | |||
| 64 | + | if err != nil || u.Host == "" { | |||
| 65 | + | return nil, fmt.Errorf("%s: the server URL %q is not a valid URL", EnvGuestForge, raw) | |||
| 66 | + | } | |||
| 67 | + | if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { | |||
| 68 | + | return nil, fmt.Errorf("%s: the server URL must be a bare origin (scheme://host[:port]): the jobs' URLs are rewritten to the origin alone", EnvGuestForge) | |||
| 69 | + | } | |||
| 70 | + | var def uint16 | |||
| 71 | + | switch u.Scheme { | |||
| 72 | + | case "https": | |||
| 73 | + | def = 443 | |||
| 74 | + | case "http": | |||
| 75 | + | def = 80 | |||
| 76 | + | if !insecureHTTP { | |||
| 77 | + | return nil, fmt.Errorf("%s: the server URL is plain http: set ERMITE_RUNNER_INSECURE_HTTP=1 to accept that sandboxes then send their tokens in clear", EnvGuestForge) | |||
| 78 | + | } | |||
| 79 | + | default: | |||
| 80 | + | return nil, fmt.Errorf("%s: the server URL must be http or https", EnvGuestForge) | |||
| 81 | + | } | |||
| 82 | + | host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) | |||
| 83 | + | if err := CheckForgeHostname(host); err != nil { | |||
| 84 | + | return nil, fmt.Errorf("%s: %w", EnvGuestForge, err) | |||
| 85 | + | } | |||
| 86 | + | port := def | |||
| 87 | + | if p := u.Port(); p != "" { | |||
| 88 | + | n, err := strconv.Atoi(p) | |||
| 89 | + | if err != nil || n < 1 || n > 65535 { | |||
| 90 | + | return nil, fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForge, p) | |||
| 91 | + | } | |||
| 92 | + | port = uint16(n) | |||
| 93 | + | } | |||
| 94 | + | origin := u.Scheme + "://" + host | |||
| 95 | + | if u.Port() != "" { | |||
| 96 | + | origin += ":" + u.Port() | |||
| 97 | + | } | |||
| 98 | + | return &GuestForge{Host: host, Port: port, Origin: origin}, nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // CheckForgeHostname accepts one DNS host name: dot separated labels of ASCII | |||
| 102 | + | // letters, digits and hyphens (not at either end), at most 63 bytes each and | |||
| 103 | + | // 253 in all. IP literals, wildcards, underscores, IDNs and empty labels are | |||
| 104 | + | // refused. | |||
| 105 | + | func CheckForgeHostname(h string) error { | |||
| 106 | + | if h == "" || len(h) > 253 { | |||
| 107 | + | return errors.New("the host name is empty or longer than 253 bytes") | |||
| 108 | + | } | |||
| 109 | + | if _, err := netip.ParseAddr(h); err == nil { | |||
| 110 | + | return fmt.Errorf("%q is an IP address: a host name is required (sandboxes are pointed at the forge by DNS)", h) | |||
| 111 | + | } | |||
| 112 | + | labels := strings.Split(h, ".") | |||
| 113 | + | allNumeric := true | |||
| 114 | + | for _, l := range labels { | |||
| 115 | + | if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' { | |||
| 116 | + | return fmt.Errorf("%q is not a valid host name", h) | |||
| 117 | + | } | |||
| 118 | + | numeric := true | |||
| 119 | + | for _, r := range l { | |||
| 120 | + | ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' | |||
| 121 | + | if !ok { | |||
| 122 | + | return fmt.Errorf("%q is not a valid host name (wildcards, underscores and non-ASCII are refused)", h) | |||
| 123 | + | } | |||
| 124 | + | if r < '0' || r > '9' { | |||
| 125 | + | numeric = false | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | allNumeric = allNumeric && numeric | |||
| 129 | + | } | |||
| 130 | + | if allNumeric { | |||
| 131 | + | // "10.1" or "2130706433": legacy spellings of an address. | |||
| 132 | + | return fmt.Errorf("%q looks like an IP address: a host name is required", h) | |||
| 133 | + | } | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // ResolveGuestForge is ParseGuestForge plus a check that the host name | |||
| 138 | + | // resolves on the host (lookup is net.DefaultResolver.LookupHost in | |||
| 139 | + | // production): the runner reaches the forge by that name, so a name that | |||
| 140 | + | // resolves to nothing is a configuration error to report at start. | |||
| 141 | + | func ResolveGuestForge(ctx context.Context, raw string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | |||
| 142 | + | g, err := ParseGuestForge(raw, insecureHTTP) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return nil, err | |||
| 145 | + | } | |||
| 146 | + | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |||
| 147 | + | defer cancel() | |||
| 148 | + | addrs, err := lookup(ctx, g.Host) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, g.Host, err) | |||
| 151 | + | } | |||
| 152 | + | if len(addrs) == 0 { | |||
| 153 | + | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, g.Host) | |||
| 154 | + | } | |||
| 155 | + | return g, nil | |||
| 156 | + | } | |||
A internal/sandbox/guestforge_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package sandbox | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestParseGuestForge(t *testing.T) { | |
| 13 | + | ok := map[string]struct { | |
| 14 | + | insecure bool | |
| 15 | + | host, origin string | |
| 16 | + | port uint16 | |
| 17 | + | }{ | |
| 18 | + | "https://ermite.example.org": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |
| 19 | + | "https://Ermite.Example.org:8443": {false, "ermite.example.org", "https://ermite.example.org:8443", 8443}, | |
| 20 | + | "https://ermite.example.org/": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |
| 21 | + | "https://ermite.example.org.": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |
| 22 | + | "http://ermite.bnema.dev:8088": {true, "ermite.bnema.dev", "http://ermite.bnema.dev:8088", 8088}, | |
| 23 | + | "http://forge": {true, "forge", "http://forge", 80}, | |
| 24 | + | "https://a-b.c1.test:65535": {false, "a-b.c1.test", "https://a-b.c1.test:65535", 65535}, | |
| 25 | + | } | |
| 26 | + | for raw, w := range ok { | |
| 27 | + | g, err := ParseGuestForge(raw, w.insecure) | |
| 28 | + | if err != nil { | |
| 29 | + | t.Errorf("%s: %v", raw, err) | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | if g.Host != w.host || g.Port != w.port || g.Origin != w.origin { | |
| 33 | + | t.Errorf("%s: %+v", raw, g) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | bad := map[string]string{ | |
| 37 | + | "http://ermite.bnema.dev:8088": "INSECURE_HTTP", // plain http needs the opt-in | |
| 38 | + | "https://10.0.0.1": "IP address", | |
| 39 | + | "https://10.0.0.1:8443": "IP address", | |
| 40 | + | "https://[::1]": "IP address", | |
| 41 | + | "https://[2001:db8::1]:8443": "IP address", | |
| 42 | + | "https://2130706433": "IP address", | |
| 43 | + | "https://127.1": "IP address", | |
| 44 | + | "https://*.example.org": "valid host name", | |
| 45 | + | "https://exa_mple.org": "valid host name", | |
| 46 | + | "https://-a.example.org": "valid host name", | |
| 47 | + | "https://a..example.org": "valid host name", | |
| 48 | + | "https://bücher.example": "valid host name", | |
| 49 | + | "https://" + strings.Repeat("a", 64) + ".test": "valid host name", | |
| 50 | + | "https://ermite.example.org:0": "1-65535", | |
| 51 | + | "https://ermite.example.org:65536": "", | |
| 52 | + | "https://ermite.example.org:99999": "", | |
| 53 | + | "https://ermite.example.org/sub": "bare origin", | |
| 54 | + | "https://ermite.example.org/?q=1": "bare origin", | |
| 55 | + | "https://u:p@ermite.example.org": "bare origin", | |
| 56 | + | "ftp://ermite.example.org": "http or https", | |
| 57 | + | "ermite.example.org": "valid URL", | |
| 58 | + | "": "valid URL", | |
| 59 | + | } | |
| 60 | + | for raw, frag := range bad { | |
| 61 | + | if _, err := ParseGuestForge(raw, false); err == nil || !strings.Contains(err.Error(), frag) || !strings.Contains(err.Error(), EnvGuestForge) { | |
| 62 | + | t.Errorf("%q: %v (want an error with %q)", raw, err, frag) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | // With the opt-in for plain http, the other refusals still apply. | |
| 66 | + | for _, raw := range []string{"http://10.0.0.1:8088", "http://localhost:99999", "http://[::1]:8088"} { | |
| 67 | + | if _, err := ParseGuestForge(raw, true); err == nil { | |
| 68 | + | t.Errorf("%q accepted", raw) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestResolveGuestForge(t *testing.T) { | |
| 74 | + | ctx := context.Background() | |
| 75 | + | var asked string | |
| 76 | + | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"127.0.0.1"}, nil } | |
| 77 | + | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", true, lookup) | |
| 78 | + | if err != nil || g.Port != 8088 || asked != "ermite.bnema.dev" { | |
| 79 | + | t.Fatalf("%+v %v %q", g, err, asked) | |
| 80 | + | } | |
| 81 | + | _, err = ResolveGuestForge(ctx, "https://nx.example.org", false, func(context.Context, string) ([]string, error) { | |
| 82 | + | return nil, errors.New("no such host") | |
| 83 | + | }) | |
| 84 | + | if err == nil || !strings.Contains(err.Error(), "does not resolve") { | |
| 85 | + | t.Fatalf("%v", err) | |
| 86 | + | } | |
| 87 | + | _, err = ResolveGuestForge(ctx, "https://empty.example.org", false, func(context.Context, string) ([]string, error) { return nil, nil }) | |
| 88 | + | if err == nil || !strings.Contains(err.Error(), "no address") { | |
| 89 | + | t.Fatalf("%v", err) | |
| 90 | + | } | |
| 91 | + | called := false | |
| 92 | + | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) | |
| 93 | + | if err == nil || called { | |
| 94 | + | t.Fatalf("an IP literal was looked up or accepted: %v", err) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestGuestForgeNilIsOff(t *testing.T) { | |
| 99 | + | var g *GuestForge | |
| 100 | + | if g.DNSName() != "" || g.TCPPort() != 0 { | |
| 101 | + | t.Fatal("nil must mean off") | |
| 102 | + | } | |
| 103 | + | g, _ = ParseGuestForge("https://ermite.example.org:8443", false) | |
| 104 | + | if g.Warning() != "guest forge access: sandboxes may reach https://ermite.example.org:8443 on the host" { | |
| 105 | + | t.Fatal(g.Warning()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestLoadConfigGuestForge(t *testing.T) { | |
| 110 | + | get := func(v string) func(string) string { | |
| 111 | + | return func(k string) string { | |
| 112 | + | if k == EnvGuestForge { | |
| 113 | + | return v | |
| 114 | + | } | |
| 115 | + | return "" | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | for v, want := range map[string]bool{"": false, "0": false, "no": false, "1": true, " true ": true, "YES": true} { | |
| 119 | + | c, err := LoadConfig(get(v)) | |
| 120 | + | if err != nil || c.GuestForgeEnabled != want { | |
| 121 | + | t.Errorf("%q: %v %v", v, c.GuestForgeEnabled, err) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if _, err := LoadConfig(get("on")); err == nil || !strings.Contains(err.Error(), EnvGuestForge) { | |
| 125 | + | t.Errorf("a bad value was accepted: %v", err) | |
| 126 | + | } | |
| 127 | + | } | |
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package sandbox | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestParseGuestForge(t *testing.T) { | |||
| 13 | + | ok := map[string]struct { | |||
| 14 | + | insecure bool | |||
| 15 | + | host, origin string | |||
| 16 | + | port uint16 | |||
| 17 | + | }{ | |||
| 18 | + | "https://ermite.example.org": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |||
| 19 | + | "https://Ermite.Example.org:8443": {false, "ermite.example.org", "https://ermite.example.org:8443", 8443}, | |||
| 20 | + | "https://ermite.example.org/": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |||
| 21 | + | "https://ermite.example.org.": {false, "ermite.example.org", "https://ermite.example.org", 443}, | |||
| 22 | + | "http://ermite.bnema.dev:8088": {true, "ermite.bnema.dev", "http://ermite.bnema.dev:8088", 8088}, | |||
| 23 | + | "http://forge": {true, "forge", "http://forge", 80}, | |||
| 24 | + | "https://a-b.c1.test:65535": {false, "a-b.c1.test", "https://a-b.c1.test:65535", 65535}, | |||
| 25 | + | } | |||
| 26 | + | for raw, w := range ok { | |||
| 27 | + | g, err := ParseGuestForge(raw, w.insecure) | |||
| 28 | + | if err != nil { | |||
| 29 | + | t.Errorf("%s: %v", raw, err) | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | if g.Host != w.host || g.Port != w.port || g.Origin != w.origin { | |||
| 33 | + | t.Errorf("%s: %+v", raw, g) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | bad := map[string]string{ | |||
| 37 | + | "http://ermite.bnema.dev:8088": "INSECURE_HTTP", // plain http needs the opt-in | |||
| 38 | + | "https://10.0.0.1": "IP address", | |||
| 39 | + | "https://10.0.0.1:8443": "IP address", | |||
| 40 | + | "https://[::1]": "IP address", | |||
| 41 | + | "https://[2001:db8::1]:8443": "IP address", | |||
| 42 | + | "https://2130706433": "IP address", | |||
| 43 | + | "https://127.1": "IP address", | |||
| 44 | + | "https://*.example.org": "valid host name", | |||
| 45 | + | "https://exa_mple.org": "valid host name", | |||
| 46 | + | "https://-a.example.org": "valid host name", | |||
| 47 | + | "https://a..example.org": "valid host name", | |||
| 48 | + | "https://bücher.example": "valid host name", | |||
| 49 | + | "https://" + strings.Repeat("a", 64) + ".test": "valid host name", | |||
| 50 | + | "https://ermite.example.org:0": "1-65535", | |||
| 51 | + | "https://ermite.example.org:65536": "", | |||
| 52 | + | "https://ermite.example.org:99999": "", | |||
| 53 | + | "https://ermite.example.org/sub": "bare origin", | |||
| 54 | + | "https://ermite.example.org/?q=1": "bare origin", | |||
| 55 | + | "https://u:p@ermite.example.org": "bare origin", | |||
| 56 | + | "ftp://ermite.example.org": "http or https", | |||
| 57 | + | "ermite.example.org": "valid URL", | |||
| 58 | + | "": "valid URL", | |||
| 59 | + | } | |||
| 60 | + | for raw, frag := range bad { | |||
| 61 | + | if _, err := ParseGuestForge(raw, false); err == nil || !strings.Contains(err.Error(), frag) || !strings.Contains(err.Error(), EnvGuestForge) { | |||
| 62 | + | t.Errorf("%q: %v (want an error with %q)", raw, err, frag) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | // With the opt-in for plain http, the other refusals still apply. | |||
| 66 | + | for _, raw := range []string{"http://10.0.0.1:8088", "http://localhost:99999", "http://[::1]:8088"} { | |||
| 67 | + | if _, err := ParseGuestForge(raw, true); err == nil { | |||
| 68 | + | t.Errorf("%q accepted", raw) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestResolveGuestForge(t *testing.T) { | |||
| 74 | + | ctx := context.Background() | |||
| 75 | + | var asked string | |||
| 76 | + | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"127.0.0.1"}, nil } | |||
| 77 | + | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", true, lookup) | |||
| 78 | + | if err != nil || g.Port != 8088 || asked != "ermite.bnema.dev" { | |||
| 79 | + | t.Fatalf("%+v %v %q", g, err, asked) | |||
| 80 | + | } | |||
| 81 | + | _, err = ResolveGuestForge(ctx, "https://nx.example.org", false, func(context.Context, string) ([]string, error) { | |||
| 82 | + | return nil, errors.New("no such host") | |||
| 83 | + | }) | |||
| 84 | + | if err == nil || !strings.Contains(err.Error(), "does not resolve") { | |||
| 85 | + | t.Fatalf("%v", err) | |||
| 86 | + | } | |||
| 87 | + | _, err = ResolveGuestForge(ctx, "https://empty.example.org", false, func(context.Context, string) ([]string, error) { return nil, nil }) | |||
| 88 | + | if err == nil || !strings.Contains(err.Error(), "no address") { | |||
| 89 | + | t.Fatalf("%v", err) | |||
| 90 | + | } | |||
| 91 | + | called := false | |||
| 92 | + | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) | |||
| 93 | + | if err == nil || called { | |||
| 94 | + | t.Fatalf("an IP literal was looked up or accepted: %v", err) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestGuestForgeNilIsOff(t *testing.T) { | |||
| 99 | + | var g *GuestForge | |||
| 100 | + | if g.DNSName() != "" || g.TCPPort() != 0 { | |||
| 101 | + | t.Fatal("nil must mean off") | |||
| 102 | + | } | |||
| 103 | + | g, _ = ParseGuestForge("https://ermite.example.org:8443", false) | |||
| 104 | + | if g.Warning() != "guest forge access: sandboxes may reach https://ermite.example.org:8443 on the host" { | |||
| 105 | + | t.Fatal(g.Warning()) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestLoadConfigGuestForge(t *testing.T) { | |||
| 110 | + | get := func(v string) func(string) string { | |||
| 111 | + | return func(k string) string { | |||
| 112 | + | if k == EnvGuestForge { | |||
| 113 | + | return v | |||
| 114 | + | } | |||
| 115 | + | return "" | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | for v, want := range map[string]bool{"": false, "0": false, "no": false, "1": true, " true ": true, "YES": true} { | |||
| 119 | + | c, err := LoadConfig(get(v)) | |||
| 120 | + | if err != nil || c.GuestForgeEnabled != want { | |||
| 121 | + | t.Errorf("%q: %v %v", v, c.GuestForgeEnabled, err) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if _, err := LoadConfig(get("on")); err == nil || !strings.Contains(err.Error(), EnvGuestForge) { | |||
| 125 | + | t.Errorf("a bad value was accepted: %v", err) | |||
| 126 | + | } | |||
| 127 | + | } | |||
M internal/sandbox/vm/driver.go +12 -2
| @@ -147,7 +147,13 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 147 | 147 | } | |
| 148 | 148 | d.net = opt.Net | |
| 149 | 149 | if d.net == nil { | |
| 150 | - | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | |
| 150 | + | n := netsetup.New(opt.Run, cfg.DNSPort, "") | |
| 151 | + | n.DNS.Override = cfg.GuestForge.DNSName() | |
| 152 | + | d.net = n | |
| 153 | + | } | |
| 154 | + | if cfg.GuestForge != nil { | |
| 155 | + | l := zerowrap.FromCtx(ctx) | |
| 156 | + | l.Warn().Msg(cfg.GuestForge.Warning()) | |
| 151 | 157 | } | |
| 152 | 158 | if err := d.net.EnableForwarding(); err != nil { | |
| 153 | 159 | return fail(err) | |
| @@ -266,6 +272,10 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co | |||
| 266 | 272 | log := zerowrap.FromCtx(ctx) | |
| 267 | 273 | d := s.d | |
| 268 | 274 | spec = d.cfg.Resolve(spec) | |
| 275 | + | if d.cfg.GuestForge != nil { | |
| 276 | + | // At every job start, as for privileged containers. | |
| 277 | + | log.Warn().Str("job", spec.JobID).Str("vm", s.id).Msg(d.cfg.GuestForge.Warning()) | |
| 278 | + | } | |
| 269 | 279 | ||
| 270 | 280 | jailed := !d.cfg.JailerOff | |
| 271 | 281 | runUID := s.uid() | |
| @@ -338,7 +348,7 @@ func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error { | |||
| 338 | 348 | return err | |
| 339 | 349 | } | |
| 340 | 350 | s.attached = true // from here Detach is idempotent and always safe | |
| 341 | - | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest}) | |
| 351 | + | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest, ForgePort: d.cfg.GuestForge.TCPPort()}) | |
| 342 | 352 | } | |
| 343 | 353 | ||
| 344 | 354 | // bootVMM starts the jailer (or Firecracker) and waits for its API. | |
| @@ -147,7 +147,13 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 147 | } | 147 | } | ||
| 148 | d.net = opt.Net | 148 | d.net = opt.Net | ||
| 149 | if d.net == nil { | 149 | if d.net == nil { | ||
| 150 | - | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | 150 | + | n := netsetup.New(opt.Run, cfg.DNSPort, "") |
| 151 | + | n.DNS.Override = cfg.GuestForge.DNSName() | |||
| 152 | + | d.net = n | |||
| 153 | + | } | |||
| 154 | + | if cfg.GuestForge != nil { | |||
| 155 | + | l := zerowrap.FromCtx(ctx) | |||
| 156 | + | l.Warn().Msg(cfg.GuestForge.Warning()) | |||
| 151 | } | 157 | } | ||
| 152 | if err := d.net.EnableForwarding(); err != nil { | 158 | if err := d.net.EnableForwarding(); err != nil { | ||
| 153 | return fail(err) | 159 | return fail(err) | ||
| @@ -266,6 +272,10 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co | |||||
| 266 | log := zerowrap.FromCtx(ctx) | 272 | log := zerowrap.FromCtx(ctx) | ||
| 267 | d := s.d | 273 | d := s.d | ||
| 268 | spec = d.cfg.Resolve(spec) | 274 | spec = d.cfg.Resolve(spec) | ||
| 275 | + | if d.cfg.GuestForge != nil { | |||
| 276 | + | // At every job start, as for privileged containers. | |||
| 277 | + | log.Warn().Str("job", spec.JobID).Str("vm", s.id).Msg(d.cfg.GuestForge.Warning()) | |||
| 278 | + | } | |||
| 269 | 279 | ||||
| 270 | jailed := !d.cfg.JailerOff | 280 | jailed := !d.cfg.JailerOff | ||
| 271 | runUID := s.uid() | 281 | runUID := s.uid() | ||
| @@ -338,7 +348,7 @@ func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error { | |||||
| 338 | return err | 348 | return err | ||
| 339 | } | 349 | } | ||
| 340 | s.attached = true // from here Detach is idempotent and always safe | 350 | s.attached = true // from here Detach is idempotent and always safe | ||
| 341 | - | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest}) | 351 | + | return d.net.Attach(ctx, netpolicy.Guest{Iface: s.tap, Gateway: netw.Gateway, Addr: netw.Guest, ForgePort: d.cfg.GuestForge.TCPPort()}) |
| 342 | } | 352 | } | ||
| 343 | 353 | ||||
| 344 | // bootVMM starts the jailer (or Firecracker) and waits for its API. | 354 | // bootVMM starts the jailer (or Firecracker) and waits for its API. | ||
M internal/sandbox/vm/vm_test.go +41 -0
| @@ -1307,3 +1307,44 @@ func TestKillAndWaitReportsAProcessThatStays(t *testing.T) { | |||
| 1307 | 1307 | t.Fatal("an unkillable process was reported gone") | |
| 1308 | 1308 | } | |
| 1309 | 1309 | } | |
| 1310 | + | ||
| 1311 | + | // Guest forge access: the exception reaches the policy of the guest, and the | |
| 1312 | + | // warning naming the origin is logged at the driver start and at every job. | |
| 1313 | + | func TestGuestForgeIsAttachedAndWarnedAtEveryJob(t *testing.T) { | |
| 1314 | + | e := newEnv(t, "") | |
| 1315 | + | var err error | |
| 1316 | + | if e.cfg.GuestForge, err = sandbox.ParseGuestForge("https://ermite.example.org:8443", false); err != nil { | |
| 1317 | + | t.Fatal(err) | |
| 1318 | + | } | |
| 1319 | + | var buf bytes.Buffer | |
| 1320 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})) | |
| 1321 | + | d, err := New(ctx, e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second}) | |
| 1322 | + | if err != nil { | |
| 1323 | + | t.Fatal(err) | |
| 1324 | + | } | |
| 1325 | + | defer func() { _ = d.Close() }() | |
| 1326 | + | msg := "guest forge access: sandboxes may reach https://ermite.example.org:8443 on the host" | |
| 1327 | + | if n := strings.Count(buf.String(), msg); n != 1 { | |
| 1328 | + | t.Fatalf("one warning at driver start, got %d", n) | |
| 1329 | + | } | |
| 1330 | + | for i := range 2 { | |
| 1331 | + | sb, _ := d.New() | |
| 1332 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: fmt.Sprint("job", i)}) | |
| 1333 | + | if err != nil { | |
| 1334 | + | t.Fatal(err) | |
| 1335 | + | } | |
| 1336 | + | if len(e.plumb.Attached) != 1 { | |
| 1337 | + | t.Fatalf("%d attached", len(e.plumb.Attached)) | |
| 1338 | + | } | |
| 1339 | + | for _, g := range e.plumb.Attached { | |
| 1340 | + | if g.ForgePort != 8443 { | |
| 1341 | + | t.Errorf("guest attached with ForgePort %d", g.ForgePort) | |
| 1342 | + | } | |
| 1343 | + | } | |
| 1344 | + | runStub(t, conn) | |
| 1345 | + | _ = sb.Stop(ctx) | |
| 1346 | + | } | |
| 1347 | + | if n := strings.Count(buf.String(), msg); n != 3 { | |
| 1348 | + | t.Fatalf("the warning must be logged at every job start (1 + 2), got %d", n) | |
| 1349 | + | } | |
| 1350 | + | } | |
| @@ -1307,3 +1307,44 @@ func TestKillAndWaitReportsAProcessThatStays(t *testing.T) { | |||||
| 1307 | t.Fatal("an unkillable process was reported gone") | 1307 | t.Fatal("an unkillable process was reported gone") | ||
| 1308 | } | 1308 | } | ||
| 1309 | } | 1309 | } | ||
| 1310 | + | ||||
| 1311 | + | // Guest forge access: the exception reaches the policy of the guest, and the | |||
| 1312 | + | // warning naming the origin is logged at the driver start and at every job. | |||
| 1313 | + | func TestGuestForgeIsAttachedAndWarnedAtEveryJob(t *testing.T) { | |||
| 1314 | + | e := newEnv(t, "") | |||
| 1315 | + | var err error | |||
| 1316 | + | if e.cfg.GuestForge, err = sandbox.ParseGuestForge("https://ermite.example.org:8443", false); err != nil { | |||
| 1317 | + | t.Fatal(err) | |||
| 1318 | + | } | |||
| 1319 | + | var buf bytes.Buffer | |||
| 1320 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})) | |||
| 1321 | + | d, err := New(ctx, e.cfg, Options{Run: e.run, Net: e.plumb, BootTimeout: 5 * time.Second}) | |||
| 1322 | + | if err != nil { | |||
| 1323 | + | t.Fatal(err) | |||
| 1324 | + | } | |||
| 1325 | + | defer func() { _ = d.Close() }() | |||
| 1326 | + | msg := "guest forge access: sandboxes may reach https://ermite.example.org:8443 on the host" | |||
| 1327 | + | if n := strings.Count(buf.String(), msg); n != 1 { | |||
| 1328 | + | t.Fatalf("one warning at driver start, got %d", n) | |||
| 1329 | + | } | |||
| 1330 | + | for i := range 2 { | |||
| 1331 | + | sb, _ := d.New() | |||
| 1332 | + | conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: fmt.Sprint("job", i)}) | |||
| 1333 | + | if err != nil { | |||
| 1334 | + | t.Fatal(err) | |||
| 1335 | + | } | |||
| 1336 | + | if len(e.plumb.Attached) != 1 { | |||
| 1337 | + | t.Fatalf("%d attached", len(e.plumb.Attached)) | |||
| 1338 | + | } | |||
| 1339 | + | for _, g := range e.plumb.Attached { | |||
| 1340 | + | if g.ForgePort != 8443 { | |||
| 1341 | + | t.Errorf("guest attached with ForgePort %d", g.ForgePort) | |||
| 1342 | + | } | |||
| 1343 | + | } | |||
| 1344 | + | runStub(t, conn) | |||
| 1345 | + | _ = sb.Stop(ctx) | |||
| 1346 | + | } | |||
| 1347 | + | if n := strings.Count(buf.String(), msg); n != 3 { | |||
| 1348 | + | t.Fatalf("the warning must be logged at every job start (1 + 2), got %d", n) | |||
| 1349 | + | } | |||
| 1350 | + | } | |||