Merge pull request 'feat(netpolicy): opt-in guest access to the forge for staging hosts' (#9) from feat/guest-forge-access into main
20 files · +1241 -15
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. That traffic comes from a private address (`10.200.x.x`), so anything behind the proxy that trusts private sources (admin pages, metrics, rate-limit exemptions) trusts job code too. 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. That traffic comes from a private address (`10.200.x.x`), so anything behind the proxy that trusts private sources (admin pages, metrics, rate-limit exemptions) trusts job code too. 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/dnsfwd/dnsfwd.go +19 -5
| @@ -12,6 +12,10 @@ | |||
| 12 | 12 | // connections, an absolute lifetime of TCPLifetime per TCP connection (a client | |
| 13 | 13 | // cannot hold a slot by trickling bytes), a deadline on every upstream | |
| 14 | 14 | // exchange, and a cap on queries per TCP connection. | |
| 15 | + | // | |
| 16 | + | // One name can be overridden (Forwarder.Override, the opt-in guest forge | |
| 17 | + | // access): queries for it are answered locally with the address of the | |
| 18 | + | // listener, see override.go. Every other name is forwarded. | |
| 15 | 19 | package dnsfwd | |
| 16 | 20 | ||
| 17 | 21 | import ( | |
| @@ -62,12 +66,18 @@ type Forwarder struct { | |||
| 62 | 66 | // exists when the network is created and a missing one is an error to | |
| 63 | 67 | // report at once, not a listener on an address nobody owns. Set before Listen. | |
| 64 | 68 | FreeBind bool | |
| 69 | + | // Override, when set, is a host name that every listener answers itself: | |
| 70 | + | // A with the address the listener is bound to, and NOERROR with no data for | |
| 71 | + | // every other type, whatever the host's resolvers say. Case-insensitive; a | |
| 72 | + | // trailing dot is ignored. Set before Listen. | |
| 73 | + | Override string | |
| 65 | 74 | ||
| 66 | 75 | mu sync.Mutex | |
| 67 | 76 | listeners map[netip.Addr]*listener | |
| 68 | 77 | } | |
| 69 | 78 | ||
| 70 | 79 | type listener struct { | |
| 80 | + | addr netip.Addr | |
| 71 | 81 | udp net.PacketConn | |
| 72 | 82 | tcp net.Listener | |
| 73 | 83 | cancel context.CancelFunc | |
| @@ -126,7 +136,7 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |||
| 126 | 136 | return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err) | |
| 127 | 137 | } | |
| 128 | 138 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 129 | - | l := &listener{udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)} | |
| 139 | + | l := &listener{addr: addr, udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)} | |
| 130 | 140 | f.listeners[addr] = l | |
| 131 | 141 | log := zerowrap.FromCtx(ctx) | |
| 132 | 142 | l.done.Add(2) | |
| @@ -211,7 +221,7 @@ func (f *Forwarder) serveUDP(ctx context.Context, l *listener, log zerowrap.Logg | |||
| 211 | 221 | q := append([]byte(nil), buf[:n]...) | |
| 212 | 222 | go func() { | |
| 213 | 223 | defer func() { <-l.inflight }() | |
| 214 | - | resp := f.exchange(ctx, q, false) | |
| 224 | + | resp := f.exchange(ctx, l, q, false) | |
| 215 | 225 | if len(resp) > 0 { | |
| 216 | 226 | _, _ = pc.WriteTo(resp, from) | |
| 217 | 227 | } | |
| @@ -277,7 +287,7 @@ func (f *Forwarder) serveConn(ctx context.Context, l *listener, c net.Conn, dead | |||
| 277 | 287 | default: | |
| 278 | 288 | return // saturated: close, the client retries | |
| 279 | 289 | } | |
| 280 | - | resp := f.exchange(ctx, q, true) | |
| 290 | + | resp := f.exchange(ctx, l, q, true) | |
| 281 | 291 | <-l.inflight | |
| 282 | 292 | if len(resp) == 0 || len(resp) > 0xffff { | |
| 283 | 293 | return | |
| @@ -293,8 +303,12 @@ func (f *Forwarder) serveConn(ctx context.Context, l *listener, c net.Conn, dead | |||
| 293 | 303 | ||
| 294 | 304 | // exchange relays q to the upstreams in order and returns the first answer, or | |
| 295 | 305 | // a SERVFAIL when none answers. The transport towards the upstream matches the | |
| 296 | - | // client's: a client that fell back to TCP gets a complete answer. | |
| 297 | - | func (f *Forwarder) exchange(ctx context.Context, q []byte, overTCP bool) []byte { | |
| 306 | + | // client's: a client that fell back to TCP gets a complete answer. The | |
| 307 | + | // overridden name, if any, is answered without asking the upstreams. | |
| 308 | + | func (f *Forwarder) exchange(ctx context.Context, l *listener, q []byte, overTCP bool) []byte { | |
| 309 | + | if resp := f.overrideAnswer(l.addr, q); resp != nil { | |
| 310 | + | return resp | |
| 311 | + | } | |
| 298 | 312 | for _, up := range f.upstreams() { | |
| 299 | 313 | var resp []byte | |
| 300 | 314 | var err error | |
| @@ -12,6 +12,10 @@ | |||||
| 12 | // connections, an absolute lifetime of TCPLifetime per TCP connection (a client | 12 | // connections, an absolute lifetime of TCPLifetime per TCP connection (a client | ||
| 13 | // cannot hold a slot by trickling bytes), a deadline on every upstream | 13 | // cannot hold a slot by trickling bytes), a deadline on every upstream | ||
| 14 | // exchange, and a cap on queries per TCP connection. | 14 | // exchange, and a cap on queries per TCP connection. | ||
| 15 | + | // | |||
| 16 | + | // One name can be overridden (Forwarder.Override, the opt-in guest forge | |||
| 17 | + | // access): queries for it are answered locally with the address of the | |||
| 18 | + | // listener, see override.go. Every other name is forwarded. | |||
| 15 | package dnsfwd | 19 | package dnsfwd | ||
| 16 | 20 | ||||
| 17 | import ( | 21 | import ( | ||
| @@ -62,12 +66,18 @@ type Forwarder struct { | |||||
| 62 | // exists when the network is created and a missing one is an error to | 66 | // exists when the network is created and a missing one is an error to | ||
| 63 | // report at once, not a listener on an address nobody owns. Set before Listen. | 67 | // report at once, not a listener on an address nobody owns. Set before Listen. | ||
| 64 | FreeBind bool | 68 | FreeBind bool | ||
| 69 | + | // Override, when set, is a host name that every listener answers itself: | |||
| 70 | + | // A with the address the listener is bound to, and NOERROR with no data for | |||
| 71 | + | // every other type, whatever the host's resolvers say. Case-insensitive; a | |||
| 72 | + | // trailing dot is ignored. Set before Listen. | |||
| 73 | + | Override string | |||
| 65 | 74 | ||||
| 66 | mu sync.Mutex | 75 | mu sync.Mutex | ||
| 67 | listeners map[netip.Addr]*listener | 76 | listeners map[netip.Addr]*listener | ||
| 68 | } | 77 | } | ||
| 69 | 78 | ||||
| 70 | type listener struct { | 79 | type listener struct { | ||
| 80 | + | addr netip.Addr | |||
| 71 | udp net.PacketConn | 81 | udp net.PacketConn | ||
| 72 | tcp net.Listener | 82 | tcp net.Listener | ||
| 73 | cancel context.CancelFunc | 83 | cancel context.CancelFunc | ||
| @@ -126,7 +136,7 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |||||
| 126 | return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err) | 136 | return fmt.Errorf("dnsfwd: listen tcp %s: %w", hp, err) | ||
| 127 | } | 137 | } | ||
| 128 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | 138 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 129 | - | l := &listener{udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)} | 139 | + | l := &listener{addr: addr, udp: pc, tcp: tl, cancel: cancel, inflight: make(chan struct{}, MaxInflight), conns: make(chan struct{}, MaxTCPConns)} |
| 130 | f.listeners[addr] = l | 140 | f.listeners[addr] = l | ||
| 131 | log := zerowrap.FromCtx(ctx) | 141 | log := zerowrap.FromCtx(ctx) | ||
| 132 | l.done.Add(2) | 142 | l.done.Add(2) | ||
| @@ -211,7 +221,7 @@ func (f *Forwarder) serveUDP(ctx context.Context, l *listener, log zerowrap.Logg | |||||
| 211 | q := append([]byte(nil), buf[:n]...) | 221 | q := append([]byte(nil), buf[:n]...) | ||
| 212 | go func() { | 222 | go func() { | ||
| 213 | defer func() { <-l.inflight }() | 223 | defer func() { <-l.inflight }() | ||
| 214 | - | resp := f.exchange(ctx, q, false) | 224 | + | resp := f.exchange(ctx, l, q, false) |
| 215 | if len(resp) > 0 { | 225 | if len(resp) > 0 { | ||
| 216 | _, _ = pc.WriteTo(resp, from) | 226 | _, _ = pc.WriteTo(resp, from) | ||
| 217 | } | 227 | } | ||
| @@ -277,7 +287,7 @@ func (f *Forwarder) serveConn(ctx context.Context, l *listener, c net.Conn, dead | |||||
| 277 | default: | 287 | default: | ||
| 278 | return // saturated: close, the client retries | 288 | return // saturated: close, the client retries | ||
| 279 | } | 289 | } | ||
| 280 | - | resp := f.exchange(ctx, q, true) | 290 | + | resp := f.exchange(ctx, l, q, true) |
| 281 | <-l.inflight | 291 | <-l.inflight | ||
| 282 | if len(resp) == 0 || len(resp) > 0xffff { | 292 | if len(resp) == 0 || len(resp) > 0xffff { | ||
| 283 | return | 293 | return | ||
| @@ -293,8 +303,12 @@ func (f *Forwarder) serveConn(ctx context.Context, l *listener, c net.Conn, dead | |||||
| 293 | 303 | ||||
| 294 | // exchange relays q to the upstreams in order and returns the first answer, or | 304 | // exchange relays q to the upstreams in order and returns the first answer, or | ||
| 295 | // a SERVFAIL when none answers. The transport towards the upstream matches the | 305 | // a SERVFAIL when none answers. The transport towards the upstream matches the | ||
| 296 | - | // client's: a client that fell back to TCP gets a complete answer. | 306 | + | // client's: a client that fell back to TCP gets a complete answer. The |
| 297 | - | func (f *Forwarder) exchange(ctx context.Context, q []byte, overTCP bool) []byte { | 307 | + | // overridden name, if any, is answered without asking the upstreams. |
| 308 | + | func (f *Forwarder) exchange(ctx context.Context, l *listener, q []byte, overTCP bool) []byte { | |||
| 309 | + | if resp := f.overrideAnswer(l.addr, q); resp != nil { | |||
| 310 | + | return resp | |||
| 311 | + | } | |||
| 298 | for _, up := range f.upstreams() { | 312 | for _, up := range f.upstreams() { | ||
| 299 | var resp []byte | 313 | var resp []byte | ||
| 300 | var err error | 314 | var err error | ||
M internal/dnsfwd/dnsfwd_test.go +146 -0
| @@ -466,3 +466,149 @@ func TestFreeBindListensOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |||
| 466 | 466 | t.Fatal("no listener") | |
| 467 | 467 | } | |
| 468 | 468 | } | |
| 469 | + | ||
| 470 | + | // queryType is query with a chosen type (and optionally EDNS0). | |
| 471 | + | func queryType(id uint16, name string, qtype uint16, edns bool) []byte { | |
| 472 | + | q := query(id, name) | |
| 473 | + | binary.BigEndian.PutUint16(q[len(q)-4:], qtype) | |
| 474 | + | if edns { | |
| 475 | + | q[11] = 1 | |
| 476 | + | q = append(q, 0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0) | |
| 477 | + | } | |
| 478 | + | return q | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func udpExchange(t *testing.T, addr string, q []byte) []byte { | |
| 482 | + | t.Helper() | |
| 483 | + | c, err := net.Dial("udp", addr) | |
| 484 | + | if err != nil { | |
| 485 | + | t.Fatal(err) | |
| 486 | + | } | |
| 487 | + | defer func() { _ = c.Close() }() | |
| 488 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 489 | + | _, _ = c.Write(q) | |
| 490 | + | buf := make([]byte, 1024) | |
| 491 | + | n, err := c.Read(buf) | |
| 492 | + | if err != nil { | |
| 493 | + | t.Fatal(err) | |
| 494 | + | } | |
| 495 | + | return buf[:n] | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | func startOverride(t *testing.T, name string, ups ...string) (*Forwarder, string) { | |
| 499 | + | t.Helper() | |
| 500 | + | f := New(func() []string { return ups }, 0) | |
| 501 | + | f.Override = name | |
| 502 | + | ip := netip.MustParseAddr("127.0.0.1") | |
| 503 | + | if err := f.Listen(context.Background(), ip); err != nil { | |
| 504 | + | t.Fatal(err) | |
| 505 | + | } | |
| 506 | + | t.Cleanup(f.CloseAll) | |
| 507 | + | a, _ := f.Addr(ip) | |
| 508 | + | return f, a.String() | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func TestOverrideAnswersAWithTheListenerAddress(t *testing.T) { | |
| 512 | + | var asked atomic.Int64 | |
| 513 | + | up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) }) | |
| 514 | + | // A trailing dot in the setting is ignored; on the wire a name has none. | |
| 515 | + | _, addr := startOverride(t, "Ermite.Example.org.", up) | |
| 516 | + | for _, name := range []string{"ermite.example.org", "ERMITE.example.ORG", "Ermite.Example.Org"} { | |
| 517 | + | r := udpExchange(t, addr, queryType(0x4242, name, 1, false)) | |
| 518 | + | if binary.BigEndian.Uint16(r) != 0x4242 || r[2]&0x80 == 0 || r[2]&0x01 == 0 || r[3]&0x0F != 0 { | |
| 519 | + | t.Fatalf("%s: header %x", name, r[:hdrLen]) | |
| 520 | + | } | |
| 521 | + | if an := binary.BigEndian.Uint16(r[6:]); an != 1 { | |
| 522 | + | t.Fatalf("%s: %d answers", name, an) | |
| 523 | + | } | |
| 524 | + | if !strings.HasSuffix(string(r), "\x00\x04\x7f\x00\x00\x01") { | |
| 525 | + | t.Fatalf("%s: rdata is not 127.0.0.1: %x", name, r) | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | if asked.Load() != 0 { | |
| 529 | + | t.Fatalf("the override went upstream %d times", asked.Load()) | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func TestOverrideAAAAIsEmptyNoError(t *testing.T) { | |
| 534 | + | up := fakeUpstream(t, answer) | |
| 535 | + | _, addr := startOverride(t, "ermite.example.org", up) | |
| 536 | + | for _, edns := range []bool{false, true} { | |
| 537 | + | r := udpExchange(t, addr, queryType(7, "ermite.EXAMPLE.org", 28, edns)) | |
| 538 | + | if r[3]&0x0F != 0 || binary.BigEndian.Uint16(r[6:]) != 0 || r[2]&0x80 == 0 { | |
| 539 | + | t.Fatalf("AAAA (edns %v): %x", edns, r) | |
| 540 | + | } | |
| 541 | + | if edns != (binary.BigEndian.Uint16(r[10:]) == 1) { | |
| 542 | + | t.Fatalf("AAAA (edns %v): arcount %d", edns, binary.BigEndian.Uint16(r[10:])) | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func TestOverrideForwardsEveryOtherName(t *testing.T) { | |
| 548 | + | var asked atomic.Int64 | |
| 549 | + | up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) }) | |
| 550 | + | _, addr := startOverride(t, "ermite.example.org", up) | |
| 551 | + | for _, name := range []string{"example.org", "other.example.org", "x.ermite.example.org", "ermite.example.org.evil.test", "ermite.example.or"} { | |
| 552 | + | r := udpExchange(t, addr, query(9, name)) | |
| 553 | + | if string(r[len(r)-6:]) != "ANSWER" { | |
| 554 | + | t.Fatalf("%s was not forwarded: %x", name, r) | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | if asked.Load() != 5 { | |
| 558 | + | t.Fatalf("%d upstream queries, want 5", asked.Load()) | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func TestOverrideOverTCPAndDisabledByDefault(t *testing.T) { | |
| 563 | + | up := fakeUpstream(t, answer) | |
| 564 | + | _, addr := startOverride(t, "ermite.example.org", up) | |
| 565 | + | tc, err := net.Dial("tcp", addr) | |
| 566 | + | if err != nil { | |
| 567 | + | t.Fatal(err) | |
| 568 | + | } | |
| 569 | + | defer func() { _ = tc.Close() }() | |
| 570 | + | _ = tc.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 571 | + | q := queryType(3, "ermite.example.org", 1, false) | |
| 572 | + | _, _ = tc.Write(append([]byte{byte(len(q) >> 8), byte(len(q))}, q...)) | |
| 573 | + | var l [2]byte | |
| 574 | + | if _, err := io.ReadFull(tc, l[:]); err != nil { | |
| 575 | + | t.Fatal(err) | |
| 576 | + | } | |
| 577 | + | r := make([]byte, binary.BigEndian.Uint16(l[:])) | |
| 578 | + | if _, err := io.ReadFull(tc, r); err != nil { | |
| 579 | + | t.Fatal(err) | |
| 580 | + | } | |
| 581 | + | if binary.BigEndian.Uint16(r[6:]) != 1 || !strings.HasSuffix(string(r), "\x7f\x00\x00\x01") { | |
| 582 | + | t.Fatalf("tcp: %x", r) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // Off by default: the name is forwarded like any other. | |
| 586 | + | _, addr2 := start(t, up) | |
| 587 | + | r2 := udpExchange(t, addr2, query(4, "ermite.example.org")) | |
| 588 | + | if string(r2[len(r2)-6:]) != "ANSWER" { | |
| 589 | + | t.Fatalf("not forwarded without Override: %x", r2) | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | func TestOverrideIgnoresMalformedAndNonQueries(t *testing.T) { | |
| 594 | + | f := New(func() []string { return nil }, 0) | |
| 595 | + | f.Override = "ermite.example.org" | |
| 596 | + | gw := netip.MustParseAddr("10.200.0.1") | |
| 597 | + | good := queryType(1, "ermite.example.org", 1, false) | |
| 598 | + | if f.overrideAnswer(gw, good) == nil { | |
| 599 | + | t.Fatal("control query not answered") | |
| 600 | + | } | |
| 601 | + | cut := good[:len(good)-3] | |
| 602 | + | chaos := queryType(1, "ermite.example.org", 1, false) | |
| 603 | + | binary.BigEndian.PutUint16(chaos[len(chaos)-2:], 3) // class CH | |
| 604 | + | resp := append([]byte(nil), good...) | |
| 605 | + | resp[2] |= 0x80 // QR set: not a query | |
| 606 | + | notify := append([]byte(nil), good...) | |
| 607 | + | notify[2] |= 4 << 3 // opcode 4 | |
| 608 | + | ptr := append(append([]byte(nil), good[:hdrLen]...), 0xC0, 0x0C, 0, 1, 0, 1) | |
| 609 | + | for name, q := range map[string][]byte{"truncated": cut, "class CH": chaos, "response": resp, "opcode": notify, "pointer": ptr, "short": good[:5]} { | |
| 610 | + | if f.overrideAnswer(gw, q) != nil { | |
| 611 | + | t.Errorf("%s was answered locally", name) | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | } | |
| @@ -466,3 +466,149 @@ func TestFreeBindListensOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |||||
| 466 | t.Fatal("no listener") | 466 | t.Fatal("no listener") | ||
| 467 | } | 467 | } | ||
| 468 | } | 468 | } | ||
| 469 | + | ||||
| 470 | + | // queryType is query with a chosen type (and optionally EDNS0). | |||
| 471 | + | func queryType(id uint16, name string, qtype uint16, edns bool) []byte { | |||
| 472 | + | q := query(id, name) | |||
| 473 | + | binary.BigEndian.PutUint16(q[len(q)-4:], qtype) | |||
| 474 | + | if edns { | |||
| 475 | + | q[11] = 1 | |||
| 476 | + | q = append(q, 0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0) | |||
| 477 | + | } | |||
| 478 | + | return q | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | func udpExchange(t *testing.T, addr string, q []byte) []byte { | |||
| 482 | + | t.Helper() | |||
| 483 | + | c, err := net.Dial("udp", addr) | |||
| 484 | + | if err != nil { | |||
| 485 | + | t.Fatal(err) | |||
| 486 | + | } | |||
| 487 | + | defer func() { _ = c.Close() }() | |||
| 488 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 489 | + | _, _ = c.Write(q) | |||
| 490 | + | buf := make([]byte, 1024) | |||
| 491 | + | n, err := c.Read(buf) | |||
| 492 | + | if err != nil { | |||
| 493 | + | t.Fatal(err) | |||
| 494 | + | } | |||
| 495 | + | return buf[:n] | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | func startOverride(t *testing.T, name string, ups ...string) (*Forwarder, string) { | |||
| 499 | + | t.Helper() | |||
| 500 | + | f := New(func() []string { return ups }, 0) | |||
| 501 | + | f.Override = name | |||
| 502 | + | ip := netip.MustParseAddr("127.0.0.1") | |||
| 503 | + | if err := f.Listen(context.Background(), ip); err != nil { | |||
| 504 | + | t.Fatal(err) | |||
| 505 | + | } | |||
| 506 | + | t.Cleanup(f.CloseAll) | |||
| 507 | + | a, _ := f.Addr(ip) | |||
| 508 | + | return f, a.String() | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func TestOverrideAnswersAWithTheListenerAddress(t *testing.T) { | |||
| 512 | + | var asked atomic.Int64 | |||
| 513 | + | up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) }) | |||
| 514 | + | // A trailing dot in the setting is ignored; on the wire a name has none. | |||
| 515 | + | _, addr := startOverride(t, "Ermite.Example.org.", up) | |||
| 516 | + | for _, name := range []string{"ermite.example.org", "ERMITE.example.ORG", "Ermite.Example.Org"} { | |||
| 517 | + | r := udpExchange(t, addr, queryType(0x4242, name, 1, false)) | |||
| 518 | + | if binary.BigEndian.Uint16(r) != 0x4242 || r[2]&0x80 == 0 || r[2]&0x01 == 0 || r[3]&0x0F != 0 { | |||
| 519 | + | t.Fatalf("%s: header %x", name, r[:hdrLen]) | |||
| 520 | + | } | |||
| 521 | + | if an := binary.BigEndian.Uint16(r[6:]); an != 1 { | |||
| 522 | + | t.Fatalf("%s: %d answers", name, an) | |||
| 523 | + | } | |||
| 524 | + | if !strings.HasSuffix(string(r), "\x00\x04\x7f\x00\x00\x01") { | |||
| 525 | + | t.Fatalf("%s: rdata is not 127.0.0.1: %x", name, r) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | if asked.Load() != 0 { | |||
| 529 | + | t.Fatalf("the override went upstream %d times", asked.Load()) | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func TestOverrideAAAAIsEmptyNoError(t *testing.T) { | |||
| 534 | + | up := fakeUpstream(t, answer) | |||
| 535 | + | _, addr := startOverride(t, "ermite.example.org", up) | |||
| 536 | + | for _, edns := range []bool{false, true} { | |||
| 537 | + | r := udpExchange(t, addr, queryType(7, "ermite.EXAMPLE.org", 28, edns)) | |||
| 538 | + | if r[3]&0x0F != 0 || binary.BigEndian.Uint16(r[6:]) != 0 || r[2]&0x80 == 0 { | |||
| 539 | + | t.Fatalf("AAAA (edns %v): %x", edns, r) | |||
| 540 | + | } | |||
| 541 | + | if edns != (binary.BigEndian.Uint16(r[10:]) == 1) { | |||
| 542 | + | t.Fatalf("AAAA (edns %v): arcount %d", edns, binary.BigEndian.Uint16(r[10:])) | |||
| 543 | + | } | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func TestOverrideForwardsEveryOtherName(t *testing.T) { | |||
| 548 | + | var asked atomic.Int64 | |||
| 549 | + | up := fakeUpstream(t, func(q []byte) []byte { asked.Add(1); return answer(q) }) | |||
| 550 | + | _, addr := startOverride(t, "ermite.example.org", up) | |||
| 551 | + | for _, name := range []string{"example.org", "other.example.org", "x.ermite.example.org", "ermite.example.org.evil.test", "ermite.example.or"} { | |||
| 552 | + | r := udpExchange(t, addr, query(9, name)) | |||
| 553 | + | if string(r[len(r)-6:]) != "ANSWER" { | |||
| 554 | + | t.Fatalf("%s was not forwarded: %x", name, r) | |||
| 555 | + | } | |||
| 556 | + | } | |||
| 557 | + | if asked.Load() != 5 { | |||
| 558 | + | t.Fatalf("%d upstream queries, want 5", asked.Load()) | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func TestOverrideOverTCPAndDisabledByDefault(t *testing.T) { | |||
| 563 | + | up := fakeUpstream(t, answer) | |||
| 564 | + | _, addr := startOverride(t, "ermite.example.org", up) | |||
| 565 | + | tc, err := net.Dial("tcp", addr) | |||
| 566 | + | if err != nil { | |||
| 567 | + | t.Fatal(err) | |||
| 568 | + | } | |||
| 569 | + | defer func() { _ = tc.Close() }() | |||
| 570 | + | _ = tc.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 571 | + | q := queryType(3, "ermite.example.org", 1, false) | |||
| 572 | + | _, _ = tc.Write(append([]byte{byte(len(q) >> 8), byte(len(q))}, q...)) | |||
| 573 | + | var l [2]byte | |||
| 574 | + | if _, err := io.ReadFull(tc, l[:]); err != nil { | |||
| 575 | + | t.Fatal(err) | |||
| 576 | + | } | |||
| 577 | + | r := make([]byte, binary.BigEndian.Uint16(l[:])) | |||
| 578 | + | if _, err := io.ReadFull(tc, r); err != nil { | |||
| 579 | + | t.Fatal(err) | |||
| 580 | + | } | |||
| 581 | + | if binary.BigEndian.Uint16(r[6:]) != 1 || !strings.HasSuffix(string(r), "\x7f\x00\x00\x01") { | |||
| 582 | + | t.Fatalf("tcp: %x", r) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // Off by default: the name is forwarded like any other. | |||
| 586 | + | _, addr2 := start(t, up) | |||
| 587 | + | r2 := udpExchange(t, addr2, query(4, "ermite.example.org")) | |||
| 588 | + | if string(r2[len(r2)-6:]) != "ANSWER" { | |||
| 589 | + | t.Fatalf("not forwarded without Override: %x", r2) | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | func TestOverrideIgnoresMalformedAndNonQueries(t *testing.T) { | |||
| 594 | + | f := New(func() []string { return nil }, 0) | |||
| 595 | + | f.Override = "ermite.example.org" | |||
| 596 | + | gw := netip.MustParseAddr("10.200.0.1") | |||
| 597 | + | good := queryType(1, "ermite.example.org", 1, false) | |||
| 598 | + | if f.overrideAnswer(gw, good) == nil { | |||
| 599 | + | t.Fatal("control query not answered") | |||
| 600 | + | } | |||
| 601 | + | cut := good[:len(good)-3] | |||
| 602 | + | chaos := queryType(1, "ermite.example.org", 1, false) | |||
| 603 | + | binary.BigEndian.PutUint16(chaos[len(chaos)-2:], 3) // class CH | |||
| 604 | + | resp := append([]byte(nil), good...) | |||
| 605 | + | resp[2] |= 0x80 // QR set: not a query | |||
| 606 | + | notify := append([]byte(nil), good...) | |||
| 607 | + | notify[2] |= 4 << 3 // opcode 4 | |||
| 608 | + | ptr := append(append([]byte(nil), good[:hdrLen]...), 0xC0, 0x0C, 0, 1, 0, 1) | |||
| 609 | + | for name, q := range map[string][]byte{"truncated": cut, "class CH": chaos, "response": resp, "opcode": notify, "pointer": ptr, "short": good[:5]} { | |||
| 610 | + | if f.overrideAnswer(gw, q) != nil { | |||
| 611 | + | t.Errorf("%s was answered locally", name) | |||
| 612 | + | } | |||
| 613 | + | } | |||
| 614 | + | } | |||
A internal/dnsfwd/override.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package dnsfwd | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "net/netip" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | typeA = 1 | |
| 13 | + | classIN = 1 | |
| 14 | + | // overrideTTL is short: the sandbox lives for one job. | |
| 15 | + | overrideTTL = 30 | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // overrideName returns Override normalised for comparison: lower case, without | |
| 19 | + | // a trailing dot. | |
| 20 | + | func (f *Forwarder) overrideName() string { | |
| 21 | + | return strings.TrimSuffix(strings.ToLower(f.Override), ".") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // questionName reads the name of the question that starts at q[hdrLen]. It | |
| 25 | + | // returns the lower-cased name without trailing dot and the offset just after | |
| 26 | + | // the name. Compression pointers and over-long labels are refused (a question | |
| 27 | + | // never uses them): ok is false and the query is forwarded as usual. | |
| 28 | + | func questionName(q []byte) (name string, end int, ok bool) { | |
| 29 | + | var sb strings.Builder | |
| 30 | + | i := hdrLen | |
| 31 | + | for { | |
| 32 | + | if i >= len(q) { | |
| 33 | + | return "", 0, false | |
| 34 | + | } | |
| 35 | + | n := int(q[i]) | |
| 36 | + | i++ | |
| 37 | + | if n == 0 { | |
| 38 | + | return sb.String(), i, true | |
| 39 | + | } | |
| 40 | + | if n&0xC0 != 0 || i+n > len(q) { | |
| 41 | + | return "", 0, false | |
| 42 | + | } | |
| 43 | + | if sb.Len() > 0 { | |
| 44 | + | sb.WriteByte('.') | |
| 45 | + | } | |
| 46 | + | sb.WriteString(strings.ToLower(string(q[i : i+n]))) | |
| 47 | + | i += n | |
| 48 | + | if sb.Len() > 253 { | |
| 49 | + | return "", 0, false | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // overrideAnswer builds the local answer to q when q asks for the overridden | |
| 55 | + | // name in class IN, else nil. A gets addr; every other type gets NOERROR with | |
| 56 | + | // no data (so AAAA is an empty answer: sandboxes have no IPv6, and an upstream | |
| 57 | + | // NXDOMAIN or CNAME for the name must not leak in). | |
| 58 | + | func (f *Forwarder) overrideAnswer(addr netip.Addr, q []byte) []byte { | |
| 59 | + | want := f.overrideName() | |
| 60 | + | if want == "" || len(q) < hdrLen { | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | // A plain query: QR=0, opcode 0, one question, nothing else but additional | |
| 64 | + | // records (EDNS). | |
| 65 | + | if q[2]&0xF8 != 0 || binary.BigEndian.Uint16(q[4:]) != 1 || | |
| 66 | + | binary.BigEndian.Uint16(q[6:]) != 0 || binary.BigEndian.Uint16(q[8:]) != 0 { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | name, end, ok := questionName(q) | |
| 70 | + | if !ok || name != want || end+4 > len(q) { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | qtype := binary.BigEndian.Uint16(q[end:]) | |
| 74 | + | if binary.BigEndian.Uint16(q[end+2:]) != classIN { | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | end += 4 | |
| 78 | + | withA := qtype == typeA && addr.Is4() | |
| 79 | + | ||
| 80 | + | r := make([]byte, 0, end+16+11) | |
| 81 | + | r = append(r, q[0], q[1]) | |
| 82 | + | r = append(r, 0x80|(q[2]&0x01), 0x80) // QR, RD copied; RA; NOERROR | |
| 83 | + | r = append(r, 0, 1) // QDCOUNT | |
| 84 | + | if withA { | |
| 85 | + | r = append(r, 0, 1) | |
| 86 | + | } else { | |
| 87 | + | r = append(r, 0, 0) | |
| 88 | + | } | |
| 89 | + | r = append(r, 0, 0) // NSCOUNT | |
| 90 | + | // An EDNS query gets an OPT record back (RFC 6891), without options. | |
| 91 | + | edns := binary.BigEndian.Uint16(q[10:]) >= 1 && len(q) >= end+11 && | |
| 92 | + | q[end] == 0 && binary.BigEndian.Uint16(q[end+1:]) == 41 | |
| 93 | + | if edns { | |
| 94 | + | r = append(r, 0, 1) | |
| 95 | + | } else { | |
| 96 | + | r = append(r, 0, 0) | |
| 97 | + | } | |
| 98 | + | r = append(r, q[hdrLen:end]...) | |
| 99 | + | if withA { | |
| 100 | + | r = append(r, 0xC0, hdrLen, 0, typeA, 0, classIN) | |
| 101 | + | r = binary.BigEndian.AppendUint32(r, overrideTTL) | |
| 102 | + | a := addr.As4() | |
| 103 | + | r = append(r, 0, 4, a[0], a[1], a[2], a[3]) | |
| 104 | + | } | |
| 105 | + | if edns { | |
| 106 | + | r = append(r, 0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0) // root, OPT, 4096 bytes, no options | |
| 107 | + | } | |
| 108 | + | return r | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package dnsfwd | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "net/netip" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | typeA = 1 | |||
| 13 | + | classIN = 1 | |||
| 14 | + | // overrideTTL is short: the sandbox lives for one job. | |||
| 15 | + | overrideTTL = 30 | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // overrideName returns Override normalised for comparison: lower case, without | |||
| 19 | + | // a trailing dot. | |||
| 20 | + | func (f *Forwarder) overrideName() string { | |||
| 21 | + | return strings.TrimSuffix(strings.ToLower(f.Override), ".") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // questionName reads the name of the question that starts at q[hdrLen]. It | |||
| 25 | + | // returns the lower-cased name without trailing dot and the offset just after | |||
| 26 | + | // the name. Compression pointers and over-long labels are refused (a question | |||
| 27 | + | // never uses them): ok is false and the query is forwarded as usual. | |||
| 28 | + | func questionName(q []byte) (name string, end int, ok bool) { | |||
| 29 | + | var sb strings.Builder | |||
| 30 | + | i := hdrLen | |||
| 31 | + | for { | |||
| 32 | + | if i >= len(q) { | |||
| 33 | + | return "", 0, false | |||
| 34 | + | } | |||
| 35 | + | n := int(q[i]) | |||
| 36 | + | i++ | |||
| 37 | + | if n == 0 { | |||
| 38 | + | return sb.String(), i, true | |||
| 39 | + | } | |||
| 40 | + | if n&0xC0 != 0 || i+n > len(q) { | |||
| 41 | + | return "", 0, false | |||
| 42 | + | } | |||
| 43 | + | if sb.Len() > 0 { | |||
| 44 | + | sb.WriteByte('.') | |||
| 45 | + | } | |||
| 46 | + | sb.WriteString(strings.ToLower(string(q[i : i+n]))) | |||
| 47 | + | i += n | |||
| 48 | + | if sb.Len() > 253 { | |||
| 49 | + | return "", 0, false | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // overrideAnswer builds the local answer to q when q asks for the overridden | |||
| 55 | + | // name in class IN, else nil. A gets addr; every other type gets NOERROR with | |||
| 56 | + | // no data (so AAAA is an empty answer: sandboxes have no IPv6, and an upstream | |||
| 57 | + | // NXDOMAIN or CNAME for the name must not leak in). | |||
| 58 | + | func (f *Forwarder) overrideAnswer(addr netip.Addr, q []byte) []byte { | |||
| 59 | + | want := f.overrideName() | |||
| 60 | + | if want == "" || len(q) < hdrLen { | |||
| 61 | + | return nil | |||
| 62 | + | } | |||
| 63 | + | // A plain query: QR=0, opcode 0, one question, nothing else but additional | |||
| 64 | + | // records (EDNS). | |||
| 65 | + | if q[2]&0xF8 != 0 || binary.BigEndian.Uint16(q[4:]) != 1 || | |||
| 66 | + | binary.BigEndian.Uint16(q[6:]) != 0 || binary.BigEndian.Uint16(q[8:]) != 0 { | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | name, end, ok := questionName(q) | |||
| 70 | + | if !ok || name != want || end+4 > len(q) { | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | qtype := binary.BigEndian.Uint16(q[end:]) | |||
| 74 | + | if binary.BigEndian.Uint16(q[end+2:]) != classIN { | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | end += 4 | |||
| 78 | + | withA := qtype == typeA && addr.Is4() | |||
| 79 | + | ||||
| 80 | + | r := make([]byte, 0, end+16+11) | |||
| 81 | + | r = append(r, q[0], q[1]) | |||
| 82 | + | r = append(r, 0x80|(q[2]&0x01), 0x80) // QR, RD copied; RA; NOERROR | |||
| 83 | + | r = append(r, 0, 1) // QDCOUNT | |||
| 84 | + | if withA { | |||
| 85 | + | r = append(r, 0, 1) | |||
| 86 | + | } else { | |||
| 87 | + | r = append(r, 0, 0) | |||
| 88 | + | } | |||
| 89 | + | r = append(r, 0, 0) // NSCOUNT | |||
| 90 | + | // An EDNS query gets an OPT record back (RFC 6891), without options. | |||
| 91 | + | edns := binary.BigEndian.Uint16(q[10:]) >= 1 && len(q) >= end+11 && | |||
| 92 | + | q[end] == 0 && binary.BigEndian.Uint16(q[end+1:]) == 41 | |||
| 93 | + | if edns { | |||
| 94 | + | r = append(r, 0, 1) | |||
| 95 | + | } else { | |||
| 96 | + | r = append(r, 0, 0) | |||
| 97 | + | } | |||
| 98 | + | r = append(r, q[hdrLen:end]...) | |||
| 99 | + | if withA { | |||
| 100 | + | r = append(r, 0xC0, hdrLen, 0, typeA, 0, classIN) | |||
| 101 | + | r = binary.BigEndian.AppendUint32(r, overrideTTL) | |||
| 102 | + | a := addr.As4() | |||
| 103 | + | r = append(r, 0, 4, a[0], a[1], a[2], a[3]) | |||
| 104 | + | } | |||
| 105 | + | if edns { | |||
| 106 | + | r = append(r, 0, 0, 41, 0x10, 0, 0, 0, 0, 0, 0, 0) // root, OPT, 4096 bytes, no options | |||
| 107 | + | } | |||
| 108 | + | return r | |||
| 109 | + | } | |||
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 +9 -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,6 +565,11 @@ 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)) | |
| @@ -632,7 +640,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo | |||
| 632 | 640 | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |
| 633 | 641 | run := &execpb.Run{Job: &execpb.JobSpec{ | |
| 634 | 642 | 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()), | |
| 643 | + | Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), | |
| 636 | 644 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | |
| 637 | 645 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | |
| 638 | 646 | }} | |
| @@ -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,6 +565,11 @@ 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)) | ||
| @@ -632,7 +640,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) { | 640 | func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | ||
| 633 | run := &execpb.Run{Job: &execpb.JobSpec{ | 641 | run := &execpb.Run{Job: &execpb.JobSpec{ | ||
| 634 | WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey, | 642 | 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()), | 643 | + | Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()), |
| 636 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | 644 | DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()), | ||
| 637 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | 645 | RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(), | ||
| 638 | }} | 646 | }} | ||
M internal/netpolicy/netns_test.go +49 -0
| @@ -72,6 +72,7 @@ func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) { | |||
| 72 | 72 | ||
| 73 | 73 | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | |
| 74 | 74 | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | |
| 75 | + | gA.ForgePort = 8088 // guest A has the guest forge exception, guest B has not | |
| 75 | 76 | for _, g := range []Guest{gA, gB} { | |
| 76 | 77 | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | |
| 77 | 78 | sh("ip", "link", "set", g.Iface, "up") | |
| @@ -188,6 +189,54 @@ except Exception: pass | |||
| 188 | 189 | t.Errorf("TEST-NET-2 reached: %s", got) | |
| 189 | 190 | } | |
| 190 | 191 | ||
| 192 | + | // Guest forge access: the host listens on 8088 and 8089 on every address | |
| 193 | + | // (like a reverse proxy on *:8088). Guest A, which has the exception for | |
| 194 | + | // 8088, reaches its own gateway there and nowhere else; guest B, which has | |
| 195 | + | // none, reaches nothing. | |
| 196 | + | const tcpServer = ` | |
| 197 | + | import socket,threading | |
| 198 | + | def serve(port): | |
| 199 | + | s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",port)); s.listen(8) | |
| 200 | + | while True: | |
| 201 | + | c,_=s.accept(); c.sendall(b"hello"); c.close() | |
| 202 | + | for p in (8088,8089): threading.Thread(target=serve,args=(p,),daemon=True).start() | |
| 203 | + | threading.Event().wait() | |
| 204 | + | ` | |
| 205 | + | tsrv := exec.Command("python3", "-c", tcpServer) | |
| 206 | + | if err := tsrv.Start(); err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | t.Cleanup(func() { _ = tsrv.Process.Kill(); _ = tsrv.Wait() }) | |
| 210 | + | time.Sleep(500 * time.Millisecond) | |
| 211 | + | const tcpClient = ` | |
| 212 | + | import socket,sys | |
| 213 | + | s=socket.socket(); s.settimeout(2) | |
| 214 | + | try: | |
| 215 | + | s.connect((sys.argv[1],int(sys.argv[2]))); print(s.recv(10).decode()) | |
| 216 | + | except Exception: print("closed") | |
| 217 | + | ` | |
| 218 | + | tcp := func(n, dst string, port int) string { | |
| 219 | + | return in(n, "python3", "-c", tcpClient, dst, fmt.Sprint(port)) | |
| 220 | + | } | |
| 221 | + | if got := tcp("a", gA.Gateway.String(), 8088); got != "hello" { | |
| 222 | + | t.Errorf("guest A cannot reach its gateway on the forge port: %s", got) | |
| 223 | + | } | |
| 224 | + | if got := tcp("a", gA.Gateway.String(), 8089); got != "closed" { | |
| 225 | + | t.Errorf("guest A reached its gateway on another port: %s", got) | |
| 226 | + | } | |
| 227 | + | if got := tcp("a", gB.Gateway.String(), 8088); got != "closed" { | |
| 228 | + | t.Errorf("guest A reached guest B's gateway on the forge port: %s", got) | |
| 229 | + | } | |
| 230 | + | if got := tcp("a", "203.0.114.1", 8088); got != "closed" { | |
| 231 | + | t.Errorf("guest A reached the host's LAN address on the forge port: %s", got) | |
| 232 | + | } | |
| 233 | + | if got := tcp("b", gB.Gateway.String(), 8088); got != "closed" { | |
| 234 | + | t.Errorf("guest B, without the exception, reached its gateway: %s", got) | |
| 235 | + | } | |
| 236 | + | if got := tcp("b", gA.Gateway.String(), 8088); got != "closed" { | |
| 237 | + | t.Errorf("guest B reached guest A's gateway on the forge port: %s", got) | |
| 238 | + | } | |
| 239 | + | ||
| 191 | 240 | out := seen() | |
| 192 | 241 | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | |
| 193 | 242 | if strings.Contains(out, bad) { | |
| @@ -72,6 +72,7 @@ func TestRulesetBetweenTwoGuestsAndAForgedSource(t *testing.T) { | |||||
| 72 | 72 | ||||
| 73 | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | 73 | gA := guest(t, "ertap0", 0) // 10.200.0.1 / .2 | ||
| 74 | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | 74 | gB := guest(t, "ertap1", 1) // 10.200.0.5 / .6 | ||
| 75 | + | gA.ForgePort = 8088 // guest A has the guest forge exception, guest B has not | |||
| 75 | for _, g := range []Guest{gA, gB} { | 76 | for _, g := range []Guest{gA, gB} { | ||
| 76 | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | 77 | sh("ip", "addr", "add", g.Gateway.String()+"/30", "dev", g.Iface) | ||
| 77 | sh("ip", "link", "set", g.Iface, "up") | 78 | sh("ip", "link", "set", g.Iface, "up") | ||
| @@ -188,6 +189,54 @@ except Exception: pass | |||||
| 188 | t.Errorf("TEST-NET-2 reached: %s", got) | 189 | t.Errorf("TEST-NET-2 reached: %s", got) | ||
| 189 | } | 190 | } | ||
| 190 | 191 | ||||
| 192 | + | // Guest forge access: the host listens on 8088 and 8089 on every address | |||
| 193 | + | // (like a reverse proxy on *:8088). Guest A, which has the exception for | |||
| 194 | + | // 8088, reaches its own gateway there and nowhere else; guest B, which has | |||
| 195 | + | // none, reaches nothing. | |||
| 196 | + | const tcpServer = ` | |||
| 197 | + | import socket,threading | |||
| 198 | + | def serve(port): | |||
| 199 | + | s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",port)); s.listen(8) | |||
| 200 | + | while True: | |||
| 201 | + | c,_=s.accept(); c.sendall(b"hello"); c.close() | |||
| 202 | + | for p in (8088,8089): threading.Thread(target=serve,args=(p,),daemon=True).start() | |||
| 203 | + | threading.Event().wait() | |||
| 204 | + | ` | |||
| 205 | + | tsrv := exec.Command("python3", "-c", tcpServer) | |||
| 206 | + | if err := tsrv.Start(); err != nil { | |||
| 207 | + | t.Fatal(err) | |||
| 208 | + | } | |||
| 209 | + | t.Cleanup(func() { _ = tsrv.Process.Kill(); _ = tsrv.Wait() }) | |||
| 210 | + | time.Sleep(500 * time.Millisecond) | |||
| 211 | + | const tcpClient = ` | |||
| 212 | + | import socket,sys | |||
| 213 | + | s=socket.socket(); s.settimeout(2) | |||
| 214 | + | try: | |||
| 215 | + | s.connect((sys.argv[1],int(sys.argv[2]))); print(s.recv(10).decode()) | |||
| 216 | + | except Exception: print("closed") | |||
| 217 | + | ` | |||
| 218 | + | tcp := func(n, dst string, port int) string { | |||
| 219 | + | return in(n, "python3", "-c", tcpClient, dst, fmt.Sprint(port)) | |||
| 220 | + | } | |||
| 221 | + | if got := tcp("a", gA.Gateway.String(), 8088); got != "hello" { | |||
| 222 | + | t.Errorf("guest A cannot reach its gateway on the forge port: %s", got) | |||
| 223 | + | } | |||
| 224 | + | if got := tcp("a", gA.Gateway.String(), 8089); got != "closed" { | |||
| 225 | + | t.Errorf("guest A reached its gateway on another port: %s", got) | |||
| 226 | + | } | |||
| 227 | + | if got := tcp("a", gB.Gateway.String(), 8088); got != "closed" { | |||
| 228 | + | t.Errorf("guest A reached guest B's gateway on the forge port: %s", got) | |||
| 229 | + | } | |||
| 230 | + | if got := tcp("a", "203.0.114.1", 8088); got != "closed" { | |||
| 231 | + | t.Errorf("guest A reached the host's LAN address on the forge port: %s", got) | |||
| 232 | + | } | |||
| 233 | + | if got := tcp("b", gB.Gateway.String(), 8088); got != "closed" { | |||
| 234 | + | t.Errorf("guest B, without the exception, reached its gateway: %s", got) | |||
| 235 | + | } | |||
| 236 | + | if got := tcp("b", gA.Gateway.String(), 8088); got != "closed" { | |||
| 237 | + | t.Errorf("guest B reached guest A's gateway on the forge port: %s", got) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 191 | out := seen() | 240 | out := seen() | ||
| 192 | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | 241 | for _, bad := range []string{"forged-src", "a-to-b", "ext-to-a", "doc-range"} { | ||
| 193 | if strings.Contains(out, bad) { | 242 | if strings.Contains(out, bad) { | ||
M internal/netpolicy/netpolicy.go +14 -0
| @@ -12,6 +12,10 @@ | |||
| 12 | 12 | // address of that guest, where the runner's DNS forwarder listens; every | |
| 13 | 13 | // other packet is dropped, and port 53 of a gateway is closed to every | |
| 14 | 14 | // other interface; | |
| 15 | + | // - optionally (Guest.ForgePort, the opt-in guest forge access, staging | |
| 16 | + | // only): TCP from the guest's source address to its own gateway address on | |
| 17 | + | // that one port. The host's own listener on that port receives the traffic; | |
| 18 | + | // nothing is proxied, and every other host port stays closed; | |
| 15 | 19 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | |
| 16 | 20 | // - forwarded traffic must carry the guest's own source address; | |
| 17 | 21 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | |
| @@ -73,6 +77,11 @@ type Guest struct { | |||
| 73 | 77 | Iface string // host-side interface: TAP or bridge name | |
| 74 | 78 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | |
| 75 | 79 | Addr netip.Addr // the guest's only allowed source address | |
| 80 | + | // ForgePort, when not 0, opens TCP from Addr to Gateway on that one port | |
| 81 | + | // (guest forge access). The accept is in the input chain, scoped to this | |
| 82 | + | // guest's interface, source and gateway, so it neither reaches other | |
| 83 | + | // addresses of the host nor other guests. | |
| 84 | + | ForgePort uint16 | |
| 76 | 85 | } | |
| 77 | 86 | ||
| 78 | 87 | // Validate checks the fields used in generated rules. | |
| @@ -155,6 +164,11 @@ func Ruleset(guests []Guest) string { | |||
| 155 | 164 | for _, proto := range []string{"udp", "tcp"} { | |
| 156 | 165 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | |
| 157 | 166 | } | |
| 167 | + | if g.ForgePort != 0 { | |
| 168 | + | // New connections only: the replies leave through the output hook, | |
| 169 | + | // which this table does not touch. | |
| 170 | + | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.ForgePort) | |
| 171 | + | } | |
| 158 | 172 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | |
| 159 | 173 | } | |
| 160 | 174 | // An interface with our prefix that no live guest owns (a leftover) is | |
| @@ -12,6 +12,10 @@ | |||||
| 12 | // address of that guest, where the runner's DNS forwarder listens; every | 12 | // address of that guest, where the runner's DNS forwarder listens; every | ||
| 13 | // other packet is dropped, and port 53 of a gateway is closed to every | 13 | // other packet is dropped, and port 53 of a gateway is closed to every | ||
| 14 | // other interface; | 14 | // other interface; | ||
| 15 | + | // - optionally (Guest.ForgePort, the opt-in guest forge access, staging | |||
| 16 | + | // only): TCP from the guest's source address to its own gateway address on | |||
| 17 | + | // that one port. The host's own listener on that port receives the traffic; | |||
| 18 | + | // nothing is proxied, and every other host port stays closed; | |||
| 15 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | 19 | // - IPv6 from the guest is dropped (the guest also disables IPv6); | ||
| 16 | // - forwarded traffic must carry the guest's own source address; | 20 | // - forwarded traffic must carry the guest's own source address; | ||
| 17 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | 21 | // - forwarded traffic to RFC 1918, CGNAT 100.64.0.0/10 (VPN overlays), | ||
| @@ -73,6 +77,11 @@ type Guest struct { | |||||
| 73 | Iface string // host-side interface: TAP or bridge name | 77 | Iface string // host-side interface: TAP or bridge name | ||
| 74 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | 78 | Gateway netip.Addr // host address on Iface; the DNS forwarder listens here | ||
| 75 | Addr netip.Addr // the guest's only allowed source address | 79 | Addr netip.Addr // the guest's only allowed source address | ||
| 80 | + | // ForgePort, when not 0, opens TCP from Addr to Gateway on that one port | |||
| 81 | + | // (guest forge access). The accept is in the input chain, scoped to this | |||
| 82 | + | // guest's interface, source and gateway, so it neither reaches other | |||
| 83 | + | // addresses of the host nor other guests. | |||
| 84 | + | ForgePort uint16 | |||
| 76 | } | 85 | } | ||
| 77 | 86 | ||||
| 78 | // Validate checks the fields used in generated rules. | 87 | // Validate checks the fields used in generated rules. | ||
| @@ -155,6 +164,11 @@ func Ruleset(guests []Guest) string { | |||||
| 155 | for _, proto := range []string{"udp", "tcp"} { | 164 | for _, proto := range []string{"udp", "tcp"} { | ||
| 156 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | 165 | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s %s dport 53 accept\n", g.Iface, g.Addr, g.Gateway, proto) | ||
| 157 | } | 166 | } | ||
| 167 | + | if g.ForgePort != 0 { | |||
| 168 | + | // New connections only: the replies leave through the output hook, | |||
| 169 | + | // which this table does not touch. | |||
| 170 | + | fmt.Fprintf(&b, "\t\tiifname \"%s\" ip saddr %s ip daddr %s tcp dport %d accept\n", g.Iface, g.Addr, g.Gateway, g.ForgePort) | |||
| 171 | + | } | |||
| 158 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | 172 | fmt.Fprintf(&b, "\t\tiifname \"%s\" drop\n", g.Iface) | ||
| 159 | } | 173 | } | ||
| 160 | // An interface with our prefix that no live guest owns (a leftover) is | 174 | // An interface with our prefix that no live guest owns (a leftover) is | ||
M internal/netpolicy/netpolicy_test.go +60 -0
| @@ -232,3 +232,63 @@ func TestManagerRollsBackOnError(t *testing.T) { | |||
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | var _ io.Reader = strings.NewReader("") | |
| 235 | + | ||
| 236 | + | func forgeGuest(t *testing.T, iface string, slot int, port uint16) Guest { | |
| 237 | + | t.Helper() | |
| 238 | + | g := guest(t, iface, slot) | |
| 239 | + | g.ForgePort = port | |
| 240 | + | return g | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestRulesetGuestForgeGolden(t *testing.T) { | |
| 244 | + | golden(t, "guest_forge.nft", Ruleset([]Guest{forgeGuest(t, "ertap0", 0, 8088)})) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Without ForgePort the ruleset has no TCP rule beyond DNS: the exception is | |
| 248 | + | // opt-in (and one.nft, the golden file of that case, is unchanged). | |
| 249 | + | func TestRulesetWithoutForgePortHasNoForgeRule(t *testing.T) { | |
| 250 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0)}) | |
| 251 | + | if n, dns := strings.Count(rs, "tcp dport"), strings.Count(rs, "tcp dport 53 "); n != dns { | |
| 252 | + | t.Fatalf("%d tcp port rules, %d of them for DNS:\n%s", n, dns, rs) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // The exception is one TCP port towards the guest's own gateway, from its own | |
| 257 | + | // address and interface, placed before the guest's catch-all drop; a second | |
| 258 | + | // guest gets its own, never the other's gateway; nothing else opens, and the | |
| 259 | + | // forward chain (private ranges, source checks, sandbox-to-sandbox) is the | |
| 260 | + | // same with and without it. | |
| 261 | + | func TestGuestForgeRuleScopeAndOrder(t *testing.T) { | |
| 262 | + | gs := []Guest{forgeGuest(t, "ertap0", 0, 8088), forgeGuest(t, "erbr1", 1, 8088), guest(t, "ertap2", 2)} | |
| 263 | + | rs := Ruleset(gs) | |
| 264 | + | in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")] | |
| 265 | + | r0 := `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept` | |
| 266 | + | r1 := `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8088 accept` | |
| 267 | + | for _, want := range []string{r0, r1} { | |
| 268 | + | if strings.Count(in, want) != 1 { | |
| 269 | + | t.Errorf("want exactly one %q in the input chain:\n%s", want, in) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | if strings.Count(rs, "8088") != 2 { | |
| 273 | + | t.Errorf("the port appears %d times, want 2 (one per guest with the exception):\n%s", strings.Count(rs, "8088"), rs) | |
| 274 | + | } | |
| 275 | + | if strings.Index(in, r0) > strings.Index(in, `iifname "ertap0" drop`) || strings.Index(in, r1) > strings.Index(in, `iifname "erbr1" drop`) { | |
| 276 | + | t.Error("the forge accept must precede the guest's catch-all drop") | |
| 277 | + | } | |
| 278 | + | // The guest without the exception has none. | |
| 279 | + | if strings.Contains(in, `iifname "ertap2" ip saddr 10.200.0.10 ip daddr 10.200.0.9 tcp dport 8088`) { | |
| 280 | + | t.Error("ertap2 got a forge rule") | |
| 281 | + | } | |
| 282 | + | // The forward and postrouting chains do not depend on the exception. | |
| 283 | + | plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)}) | |
| 284 | + | tail := func(s string) string { return s[strings.Index(s, "chain forward"):] } | |
| 285 | + | if tail(rs) != tail(plain) { | |
| 286 | + | t.Error("the forward/postrouting chains changed with the exception") | |
| 287 | + | } | |
| 288 | + | // Every accept of the input chain that is not DNS is one of the two. | |
| 289 | + | for _, l := range strings.Split(in, "\n") { | |
| 290 | + | if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") { | |
| 291 | + | t.Errorf("unexpected accept: %s", l) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| @@ -232,3 +232,63 @@ func TestManagerRollsBackOnError(t *testing.T) { | |||||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | var _ io.Reader = strings.NewReader("") | 234 | var _ io.Reader = strings.NewReader("") | ||
| 235 | + | ||||
| 236 | + | func forgeGuest(t *testing.T, iface string, slot int, port uint16) Guest { | |||
| 237 | + | t.Helper() | |||
| 238 | + | g := guest(t, iface, slot) | |||
| 239 | + | g.ForgePort = port | |||
| 240 | + | return g | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestRulesetGuestForgeGolden(t *testing.T) { | |||
| 244 | + | golden(t, "guest_forge.nft", Ruleset([]Guest{forgeGuest(t, "ertap0", 0, 8088)})) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Without ForgePort the ruleset has no TCP rule beyond DNS: the exception is | |||
| 248 | + | // opt-in (and one.nft, the golden file of that case, is unchanged). | |||
| 249 | + | func TestRulesetWithoutForgePortHasNoForgeRule(t *testing.T) { | |||
| 250 | + | rs := Ruleset([]Guest{guest(t, "ertap0", 0)}) | |||
| 251 | + | if n, dns := strings.Count(rs, "tcp dport"), strings.Count(rs, "tcp dport 53 "); n != dns { | |||
| 252 | + | t.Fatalf("%d tcp port rules, %d of them for DNS:\n%s", n, dns, rs) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // The exception is one TCP port towards the guest's own gateway, from its own | |||
| 257 | + | // address and interface, placed before the guest's catch-all drop; a second | |||
| 258 | + | // guest gets its own, never the other's gateway; nothing else opens, and the | |||
| 259 | + | // forward chain (private ranges, source checks, sandbox-to-sandbox) is the | |||
| 260 | + | // same with and without it. | |||
| 261 | + | func TestGuestForgeRuleScopeAndOrder(t *testing.T) { | |||
| 262 | + | gs := []Guest{forgeGuest(t, "ertap0", 0, 8088), forgeGuest(t, "erbr1", 1, 8088), guest(t, "ertap2", 2)} | |||
| 263 | + | rs := Ruleset(gs) | |||
| 264 | + | in := rs[strings.Index(rs, "chain input"):strings.Index(rs, "chain forward")] | |||
| 265 | + | r0 := `iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept` | |||
| 266 | + | r1 := `iifname "erbr1" ip saddr 10.200.0.6 ip daddr 10.200.0.5 tcp dport 8088 accept` | |||
| 267 | + | for _, want := range []string{r0, r1} { | |||
| 268 | + | if strings.Count(in, want) != 1 { | |||
| 269 | + | t.Errorf("want exactly one %q in the input chain:\n%s", want, in) | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | if strings.Count(rs, "8088") != 2 { | |||
| 273 | + | t.Errorf("the port appears %d times, want 2 (one per guest with the exception):\n%s", strings.Count(rs, "8088"), rs) | |||
| 274 | + | } | |||
| 275 | + | if strings.Index(in, r0) > strings.Index(in, `iifname "ertap0" drop`) || strings.Index(in, r1) > strings.Index(in, `iifname "erbr1" drop`) { | |||
| 276 | + | t.Error("the forge accept must precede the guest's catch-all drop") | |||
| 277 | + | } | |||
| 278 | + | // The guest without the exception has none. | |||
| 279 | + | if strings.Contains(in, `iifname "ertap2" ip saddr 10.200.0.10 ip daddr 10.200.0.9 tcp dport 8088`) { | |||
| 280 | + | t.Error("ertap2 got a forge rule") | |||
| 281 | + | } | |||
| 282 | + | // The forward and postrouting chains do not depend on the exception. | |||
| 283 | + | plain := Ruleset([]Guest{guest(t, "ertap0", 0), guest(t, "erbr1", 1), guest(t, "ertap2", 2)}) | |||
| 284 | + | tail := func(s string) string { return s[strings.Index(s, "chain forward"):] } | |||
| 285 | + | if tail(rs) != tail(plain) { | |||
| 286 | + | t.Error("the forward/postrouting chains changed with the exception") | |||
| 287 | + | } | |||
| 288 | + | // Every accept of the input chain that is not DNS is one of the two. | |||
| 289 | + | for _, l := range strings.Split(in, "\n") { | |||
| 290 | + | if strings.HasSuffix(l, "accept") && !strings.Contains(l, "dport 53 accept") && !strings.Contains(l, "tcp dport 8088 accept") { | |||
| 291 | + | t.Errorf("unexpected accept: %s", l) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | } | |||
A internal/netpolicy/testdata/guest_forge.nft +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | table inet ermite_runner | |
| 2 | + | delete table inet ermite_runner | |
| 3 | + | table inet ermite_runner { | |
| 4 | + | set blocked_v4 { | |
| 5 | + | type ipv4_addr | |
| 6 | + | flags interval | |
| 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } | |
| 8 | + | } | |
| 9 | + | chain input { | |
| 10 | + | type filter hook input priority filter; policy accept; | |
| 11 | + | iifname "ertap*" meta nfproto ipv6 drop | |
| 12 | + | iifname "erbr*" meta nfproto ipv6 drop | |
| 13 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |
| 14 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |
| 15 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | |
| 16 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | |
| 17 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept | |
| 18 | + | iifname "ertap0" drop | |
| 19 | + | iifname "ertap*" drop | |
| 20 | + | iifname "erbr*" drop | |
| 21 | + | } | |
| 22 | + | chain forward { | |
| 23 | + | type filter hook forward priority filter; policy accept; | |
| 24 | + | iifname "ertap*" meta nfproto ipv6 drop | |
| 25 | + | iifname "erbr*" meta nfproto ipv6 drop | |
| 26 | + | iifname "ertap0" ip saddr != 10.200.0.2 drop | |
| 27 | + | iifname "ertap0" ip daddr @blocked_v4 drop | |
| 28 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |
| 29 | + | oifname "ertap*" drop | |
| 30 | + | oifname "erbr*" drop | |
| 31 | + | iifname "ertap0" accept | |
| 32 | + | iifname "ertap*" drop | |
| 33 | + | iifname "erbr*" drop | |
| 34 | + | } | |
| 35 | + | chain postrouting { | |
| 36 | + | type nat hook postrouting priority srcnat; policy accept; | |
| 37 | + | ip saddr 10.200.0.2 oifname != "ertap0" masquerade | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | table inet ermite_runner | |||
| 2 | + | delete table inet ermite_runner | |||
| 3 | + | table inet ermite_runner { | |||
| 4 | + | set blocked_v4 { | |||
| 5 | + | type ipv4_addr | |||
| 6 | + | flags interval | |||
| 7 | + | elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } | |||
| 8 | + | } | |||
| 9 | + | chain input { | |||
| 10 | + | type filter hook input priority filter; policy accept; | |||
| 11 | + | iifname "ertap*" meta nfproto ipv6 drop | |||
| 12 | + | iifname "erbr*" meta nfproto ipv6 drop | |||
| 13 | + | ip daddr 10.200.0.1 udp dport 53 iifname != "ertap0" drop | |||
| 14 | + | ip daddr 10.200.0.1 tcp dport 53 iifname != "ertap0" drop | |||
| 15 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 udp dport 53 accept | |||
| 16 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 53 accept | |||
| 17 | + | iifname "ertap0" ip saddr 10.200.0.2 ip daddr 10.200.0.1 tcp dport 8088 accept | |||
| 18 | + | iifname "ertap0" drop | |||
| 19 | + | iifname "ertap*" drop | |||
| 20 | + | iifname "erbr*" drop | |||
| 21 | + | } | |||
| 22 | + | chain forward { | |||
| 23 | + | type filter hook forward priority filter; policy accept; | |||
| 24 | + | iifname "ertap*" meta nfproto ipv6 drop | |||
| 25 | + | iifname "erbr*" meta nfproto ipv6 drop | |||
| 26 | + | iifname "ertap0" ip saddr != 10.200.0.2 drop | |||
| 27 | + | iifname "ertap0" ip daddr @blocked_v4 drop | |||
| 28 | + | iifname != { "ertap*", "erbr*" } oifname { "ertap*", "erbr*" } ct state established,related accept | |||
| 29 | + | oifname "ertap*" drop | |||
| 30 | + | oifname "erbr*" drop | |||
| 31 | + | iifname "ertap0" accept | |||
| 32 | + | iifname "ertap*" drop | |||
| 33 | + | iifname "erbr*" drop | |||
| 34 | + | } | |||
| 35 | + | chain postrouting { | |||
| 36 | + | type nat hook postrouting priority srcnat; policy accept; | |||
| 37 | + | ip saddr 10.200.0.2 oifname != "ertap0" masquerade | |||
| 38 | + | } | |||
| 39 | + | } | |||
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 | + | } | |||