detached 6699244

feat(runner): opt-in guest access to the forge host:port of ERMITE_RUNNER_URL

ERMITE_RUNNER_GUEST_FORGE=1 points sandboxes at the forge the way the runner
reaches it: DNS override, one nft accept, and the server URLs of the job
rewritten to the ERMITE_RUNNER_URL origin. Staging only.
commit
669924454014efc26c64c23e656ab307ae239c96
author
brice · · 2026-10-13 07:19 UTC
parent
853c006
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +806 -10
M README.md +17 -2
@@ -37,10 +37,23 @@ Reaping removes every process, device, firewall rule and Docker object the runne
3737
3838 ### Network policy (both modes)
3939
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.
4141
4242 **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).
4343
44+### Guest forge access (opt-in, staging only)
45+
46+Off by default. `ERMITE_RUNNER_GUEST_FORGE=1` lets sandboxes reach the forge **the way the runner does**, for a staging host where ermite is only reachable through an overlay (NetBird) and the runner itself uses a local route, e.g. `/etc/hosts` `ermite.bnema.dev -> 127.0.0.1` and a reverse proxy (Gordon) on `*:8088` with `ERMITE_RUNNER_URL=http://ermite.bnema.dev:8088`. Jobs otherwise cannot `git clone ${{ github.server_url }}/...`: the public URL (`https://ermite.bnema.dev`) is TLS-terminated off-host, and the private ranges are denied.
47+
48+The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the scheme: 443 or 80). The runner **refuses to start** when the URL host is an IP literal (sandboxes are pointed at the host by DNS), when the URL has a path, when the name does not resolve on the host, or when the URL is plain `http://` without `ERMITE_RUNNER_INSECURE_HTTP=1` (same rule as for the runner itself: tokens then travel in clear). Then, for every sandbox in both modes:
49+
50+- the DNS forwarder answers `A` queries for exactly that host name (case-insensitive, trailing dot ignored) with the sandbox's **gateway address**, and `AAAA` with an empty `NOERROR` answer; every other name is forwarded as before;
51+- the nftables input chain accepts TCP from the sandbox's own address to its own gateway address on that one port, before the sandbox's catch-all drop (one extra line per sandbox; see `internal/netpolicy/testdata/guest_forge.nft`). Nothing else on the host opens; the forward chain (private-range denies, source check, no sandbox-to-sandbox) is unchanged;
52+- the `github.server_url`, `github.api_url` and `github.graphql_url` of the job's contexts, and so `GITHUB_SERVER_URL`, `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL`, are rewritten to the origin of `ERMITE_RUNNER_URL` when their origin is the server's public one (`github.server_url`); paths are kept (`/-/github/api`). Other values, and the URLs inside `github.event`, stay as the server recorded them;
53+- `guest forge access: sandboxes may reach <origin> on the host` is logged at start and at the start of every job, like the privileged-container warning.
54+
55+**The runner does not proxy.** The traffic reaches the host's own listener on that port, so a service must listen on it on an address the gateway reaches (`*`, as Gordon does; a listener bound to `127.0.0.1` receives nothing). A shared reverse proxy on that port routes by `Host` header, so **a job can reach every app that proxy serves on that port**, not only the forge, by sending another `Host` header: use this on staging hosts only, with a proxy whose other apps you accept to expose to job code. Jobs also see the forge over plain HTTP in that setup.
56+
4457 ### Cancellation
4558
4659 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.
96109 | `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
97110 | `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 |
98111 | `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") |
100114 | `ERMITE_RUNNER_LOG_LEVEL` | `debug`, `info` (default), `warn`, `error` |
101115 | `ERMITE_RUNNER_LOG_FORMAT` | `console` (default, colours) or `json` |
102116
@@ -246,6 +260,7 @@ the moment it starts.
246260 | `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) |
247261 | `ERMITE_RUNNER_JOB_PIDS` | `32768` | container process limit |
248262 | `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") |
249264 | `ERMITE_RUNNER_LOCK_FILE` | `/run/ermite-runner.lock` | host-wide single-instance lock (tests only) |
250265 | `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 |
251266 | `ERMITE_RUNNER_UID_BASE` | `900000` | jailer uid pool start |
@@ -37,10 +37,23 @@ Reaping removes every process, device, firewall rule and Docker object the runne
37 37
38 ### Network policy (both modes)38 ### Network policy (both modes)
39 39
40-One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address, where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.40+One nftables table, `inet ermite_runner`, replaced atomically (`nft -f -`) and deleted with the last sandbox; nothing else is touched. From a sandbox: to the host only DNS to its own gateway address (plus one TCP port when the opt-in guest forge access is on, below), where the runner's DNS forwarder listens (UDP and TCP, forwarding to the resolvers of `/etc/resolv.conf`; port 53 of a gateway is closed to every other interface); no traffic to RFC 1918, `100.64.0.0/10`, link-local (including `169.254.169.254`), loopback, the documentation, benchmarking and IETF-assigned ranges, multicast or reserved ranges; no IPv6; no traffic between sandboxes; the rest is NATed. A sandbox's own checks (source address, destination ranges) run before any "established" accept, so a guest cannot forge another's flow. The golden rulesets are in `internal/netpolicy/testdata`. The DNS forwarder limits each listener to 32 in-flight queries (UDP beyond that is dropped), 16 TCP connections, and 30 s per TCP connection.
41 41
42 **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes).42 **Side effect on the host:** the runner turns `net.ipv4.ip_forward` on (NAT needs it) and leaves it on when it exits. Forwarding is host-wide, not scoped to the runner's interfaces: if the machine must not route for other networks, restrict it with its own firewall rules (the runner's table only governs traffic of its sandboxes).
43 43
44+### Guest forge access (opt-in, staging only)
45+
46+Off by default. `ERMITE_RUNNER_GUEST_FORGE=1` lets sandboxes reach the forge **the way the runner does**, for a staging host where ermite is only reachable through an overlay (NetBird) and the runner itself uses a local route, e.g. `/etc/hosts` `ermite.bnema.dev -> 127.0.0.1` and a reverse proxy (Gordon) on `*:8088` with `ERMITE_RUNNER_URL=http://ermite.bnema.dev:8088`. Jobs otherwise cannot `git clone ${{ github.server_url }}/...`: the public URL (`https://ermite.bnema.dev`) is TLS-terminated off-host, and the private ranges are denied.
47+
48+The host name and port come from `ERMITE_RUNNER_URL` (port defaulted from the scheme: 443 or 80). The runner **refuses to start** when the URL host is an IP literal (sandboxes are pointed at the host by DNS), when the URL has a path, when the name does not resolve on the host, or when the URL is plain `http://` without `ERMITE_RUNNER_INSECURE_HTTP=1` (same rule as for the runner itself: tokens then travel in clear). Then, for every sandbox in both modes:
49+
50+- the DNS forwarder answers `A` queries for exactly that host name (case-insensitive, trailing dot ignored) with the sandbox's **gateway address**, and `AAAA` with an empty `NOERROR` answer; every other name is forwarded as before;
51+- the nftables input chain accepts TCP from the sandbox's own address to its own gateway address on that one port, before the sandbox's catch-all drop (one extra line per sandbox; see `internal/netpolicy/testdata/guest_forge.nft`). Nothing else on the host opens; the forward chain (private-range denies, source check, no sandbox-to-sandbox) is unchanged;
52+- the `github.server_url`, `github.api_url` and `github.graphql_url` of the job's contexts, and so `GITHUB_SERVER_URL`, `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL`, are rewritten to the origin of `ERMITE_RUNNER_URL` when their origin is the server's public one (`github.server_url`); paths are kept (`/-/github/api`). Other values, and the URLs inside `github.event`, stay as the server recorded them;
53+- `guest forge access: sandboxes may reach <origin> on the host` is logged at start and at the start of every job, like the privileged-container warning.
54+
55+**The runner does not proxy.** The traffic reaches the host's own listener on that port, so a service must listen on it on an address the gateway reaches (`*`, as Gordon does; a listener bound to `127.0.0.1` receives nothing). A shared reverse proxy on that port routes by `Host` header, so **a job can reach every app that proxy serves on that port**, not only the forge, by sending another `Host` header: use this on staging hosts only, with a proxy whose other apps you accept to expose to job code. Jobs also see the forge over plain HTTP in that setup.
56+
44 ### Cancellation57 ### 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 (
77 "errors"
88 "flag"
99 "fmt"
10+ "net"
1011 "os"
1112
1213 "github.com/bnema/zerowrap"
@@ -104,6 +105,17 @@ func runCmd(ctx context.Context) int {
104105 fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
105106 return 2
106107 }
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+ }
107119 drv, err := buildDriver(ctx, scfg)
108120 if err != nil {
109121 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 2106 return 2
106 }107 }
108+ if scfg.GuestForgeEnabled {
109+ // The sandboxes reach the forge the way the runner does: by the host name
110+ // and port of the URL the runner itself uses.
111+ gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost)
112+ if err != nil {
113+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
114+ return 2
115+ }
116+ scfg.GuestForge = gf
117+ hcfg.GuestForgeOrigin = gf.Origin
118+ }
107 drv, err := buildDriver(ctx, scfg)119 drv, err := buildDriver(ctx, scfg)
108 if err != nil {120 if err != nil {
109 fmt.Fprintln(os.Stderr, "ermite-runner:", err)121 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
M internal/host/config.go +5 -0
@@ -62,6 +62,11 @@ type Config struct {
6262 Environment string
6363 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
6464 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
6570 }
6671
6772 // Client returns the client for a token, honouring InsecureHTTP.
@@ -62,6 +62,11 @@ type Config struct {
62 Environment string62 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 bool64 InsecureHTTP bool
65+ // GuestForgeOrigin is the origin (scheme://host[:port]) of ERMITE_RUNNER_URL
66+ // when ERMITE_RUNNER_GUEST_FORGE=1, "" otherwise. The server URLs of every
67+ // job are rewritten to it, see RewriteServerURLs. Set by the caller after
68+ // sandbox.ResolveGuestForge.
69+ GuestForgeOrigin string
65 }70 }
66 71
67 // Client returns the client for a token, honouring InsecureHTTP.72 // Client returns the client for a token, honouring InsecureHTTP.
A internal/host/guestforge.go +189 -0
@@ -0,0 +1,189 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/url"
12+ "strings"
13+)
14+
15+// serverURLKeys are the keys of the github context that actions use to reach
16+// the forge: the server's web URL, its REST API and its GraphQL API. The
17+// environment variables GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_GRAPHQL_URL
18+// are derived from them by the executor (State.RuntimeEnv), so rewriting the
19+// context rewrites the environment too. Everything else that carries the
20+// origin (the URLs inside github.event, recorded as the server built them;
21+// repositoryUrl, which is a git:// URL) is left alone.
22+var serverURLKeys = []string{"server_url", "api_url", "graphql_url"}
23+
24+type jsonMember struct {
25+ key string
26+ val json.RawMessage
27+}
28+
29+// RewriteServerURLs returns the contexts JSON of a job with the origin of
30+// github.server_url, github.api_url and github.graphql_url replaced by target
31+// (scheme://host[:port], the ERMITE_RUNNER_URL origin) when, and only when, the
32+// value has the same origin as github.server_url, the server's public origin.
33+// Paths, queries and fragments are kept ("/-/github/api"); values with another
34+// origin, with credentials, or that are not strings are untouched, as is the
35+// rest of the document (order of keys included). The input is returned as it
36+// is when nothing changes.
37+func RewriteServerURLs(contexts, target string) (string, error) {
38+ if target == "" {
39+ return contexts, nil
40+ }
41+ top, err := parseObject([]byte(contexts))
42+ if err != nil {
43+ return "", fmt.Errorf("job contexts: %w", err)
44+ }
45+ gi := -1
46+ for i, m := range top {
47+ if m.key == "github" {
48+ gi = i
49+ }
50+ }
51+ if gi < 0 {
52+ return contexts, nil
53+ }
54+ gh, err := parseObject(top[gi].val)
55+ if err != nil {
56+ return "", fmt.Errorf("job contexts: github: %w", err)
57+ }
58+ str := func(raw json.RawMessage) (string, bool) {
59+ var s string
60+ return s, json.Unmarshal(raw, &s) == nil
61+ }
62+ var public string
63+ for _, m := range gh {
64+ if m.key == "server_url" {
65+ if s, ok := str(m.val); ok {
66+ public = originOf(s)
67+ }
68+ }
69+ }
70+ if public == "" || public == originOf(target) {
71+ return contexts, nil
72+ }
73+ changed := false
74+ for i, m := range gh {
75+ isURL := false
76+ for _, k := range serverURLKeys {
77+ isURL = isURL || m.key == k
78+ }
79+ if !isURL {
80+ continue
81+ }
82+ s, ok := str(m.val)
83+ if !ok || originOf(s) != public {
84+ continue
85+ }
86+ rest := s[strings.Index(s, "://")+3:]
87+ if k := strings.IndexAny(rest, "/?#"); k >= 0 {
88+ rest = rest[k:]
89+ } else {
90+ rest = ""
91+ }
92+ nv, err := marshalNoEscape(target + rest)
93+ if err != nil {
94+ return "", err
95+ }
96+ gh[i].val = nv
97+ changed = true
98+ }
99+ if !changed {
100+ return contexts, nil
101+ }
102+ if top[gi].val, err = encodeObject(gh); err != nil {
103+ return "", err
104+ }
105+ out, err := encodeObject(top)
106+ if err != nil {
107+ return "", err
108+ }
109+ return string(out), nil
110+}
111+
112+// originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
113+// without the default port, or "" for anything else (including URLs with
114+// credentials, which are never rewritten).
115+func originOf(raw string) string {
116+ u, err := url.Parse(raw)
117+ if err != nil || u.Host == "" || u.User != nil {
118+ return ""
119+ }
120+ scheme := strings.ToLower(u.Scheme)
121+ if scheme != "http" && scheme != "https" {
122+ return ""
123+ }
124+ host, port := strings.ToLower(u.Hostname()), u.Port()
125+ if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
126+ return scheme + "://" + host
127+ }
128+ return scheme + "://" + host + ":" + port
129+}
130+
131+func marshalNoEscape(v any) (json.RawMessage, error) {
132+ var b bytes.Buffer
133+ enc := json.NewEncoder(&b)
134+ enc.SetEscapeHTML(false)
135+ if err := enc.Encode(v); err != nil {
136+ return nil, err
137+ }
138+ return bytes.TrimRight(b.Bytes(), "\n"), nil
139+}
140+
141+// parseObject reads a JSON object into its members, in order.
142+func parseObject(b []byte) ([]jsonMember, error) {
143+ dec := json.NewDecoder(bytes.NewReader(b))
144+ if t, err := dec.Token(); err != nil || t != json.Delim('{') {
145+ return nil, errors.New("not a JSON object")
146+ }
147+ var out []jsonMember
148+ for dec.More() {
149+ kt, err := dec.Token()
150+ if err != nil {
151+ return nil, err
152+ }
153+ k, ok := kt.(string)
154+ if !ok {
155+ return nil, errors.New("not a JSON object")
156+ }
157+ var v json.RawMessage
158+ if err := dec.Decode(&v); err != nil {
159+ return nil, err
160+ }
161+ out = append(out, jsonMember{k, v})
162+ }
163+ if _, err := dec.Token(); err != nil {
164+ return nil, err
165+ }
166+ if _, err := dec.Token(); err != io.EOF {
167+ return nil, errors.New("data after the JSON object")
168+ }
169+ return out, nil
170+}
171+
172+func encodeObject(ms []jsonMember) (json.RawMessage, error) {
173+ var b bytes.Buffer
174+ b.WriteByte('{')
175+ for i, m := range ms {
176+ if i > 0 {
177+ b.WriteByte(',')
178+ }
179+ k, err := marshalNoEscape(m.key)
180+ if err != nil {
181+ return nil, err
182+ }
183+ b.Write(k)
184+ b.WriteByte(':')
185+ b.Write(m.val)
186+ }
187+ b.WriteByte('}')
188+ return b.Bytes(), nil
189+}
@@ -0,0 +1,189 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/url"
12+ "strings"
13+)
14+
15+// serverURLKeys are the keys of the github context that actions use to reach
16+// the forge: the server's web URL, its REST API and its GraphQL API. The
17+// environment variables GITHUB_SERVER_URL, GITHUB_API_URL and GITHUB_GRAPHQL_URL
18+// are derived from them by the executor (State.RuntimeEnv), so rewriting the
19+// context rewrites the environment too. Everything else that carries the
20+// origin (the URLs inside github.event, recorded as the server built them;
21+// repositoryUrl, which is a git:// URL) is left alone.
22+var serverURLKeys = []string{"server_url", "api_url", "graphql_url"}
23+
24+type jsonMember struct {
25+ key string
26+ val json.RawMessage
27+}
28+
29+// RewriteServerURLs returns the contexts JSON of a job with the origin of
30+// github.server_url, github.api_url and github.graphql_url replaced by target
31+// (scheme://host[:port], the ERMITE_RUNNER_URL origin) when, and only when, the
32+// value has the same origin as github.server_url, the server's public origin.
33+// Paths, queries and fragments are kept ("/-/github/api"); values with another
34+// origin, with credentials, or that are not strings are untouched, as is the
35+// rest of the document (order of keys included). The input is returned as it
36+// is when nothing changes.
37+func RewriteServerURLs(contexts, target string) (string, error) {
38+ if target == "" {
39+ return contexts, nil
40+ }
41+ top, err := parseObject([]byte(contexts))
42+ if err != nil {
43+ return "", fmt.Errorf("job contexts: %w", err)
44+ }
45+ gi := -1
46+ for i, m := range top {
47+ if m.key == "github" {
48+ gi = i
49+ }
50+ }
51+ if gi < 0 {
52+ return contexts, nil
53+ }
54+ gh, err := parseObject(top[gi].val)
55+ if err != nil {
56+ return "", fmt.Errorf("job contexts: github: %w", err)
57+ }
58+ str := func(raw json.RawMessage) (string, bool) {
59+ var s string
60+ return s, json.Unmarshal(raw, &s) == nil
61+ }
62+ var public string
63+ for _, m := range gh {
64+ if m.key == "server_url" {
65+ if s, ok := str(m.val); ok {
66+ public = originOf(s)
67+ }
68+ }
69+ }
70+ if public == "" || public == originOf(target) {
71+ return contexts, nil
72+ }
73+ changed := false
74+ for i, m := range gh {
75+ isURL := false
76+ for _, k := range serverURLKeys {
77+ isURL = isURL || m.key == k
78+ }
79+ if !isURL {
80+ continue
81+ }
82+ s, ok := str(m.val)
83+ if !ok || originOf(s) != public {
84+ continue
85+ }
86+ rest := s[strings.Index(s, "://")+3:]
87+ if k := strings.IndexAny(rest, "/?#"); k >= 0 {
88+ rest = rest[k:]
89+ } else {
90+ rest = ""
91+ }
92+ nv, err := marshalNoEscape(target + rest)
93+ if err != nil {
94+ return "", err
95+ }
96+ gh[i].val = nv
97+ changed = true
98+ }
99+ if !changed {
100+ return contexts, nil
101+ }
102+ if top[gi].val, err = encodeObject(gh); err != nil {
103+ return "", err
104+ }
105+ out, err := encodeObject(top)
106+ if err != nil {
107+ return "", err
108+ }
109+ return string(out), nil
110+}
111+
112+// originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
113+// without the default port, or "" for anything else (including URLs with
114+// credentials, which are never rewritten).
115+func originOf(raw string) string {
116+ u, err := url.Parse(raw)
117+ if err != nil || u.Host == "" || u.User != nil {
118+ return ""
119+ }
120+ scheme := strings.ToLower(u.Scheme)
121+ if scheme != "http" && scheme != "https" {
122+ return ""
123+ }
124+ host, port := strings.ToLower(u.Hostname()), u.Port()
125+ if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
126+ return scheme + "://" + host
127+ }
128+ return scheme + "://" + host + ":" + port
129+}
130+
131+func marshalNoEscape(v any) (json.RawMessage, error) {
132+ var b bytes.Buffer
133+ enc := json.NewEncoder(&b)
134+ enc.SetEscapeHTML(false)
135+ if err := enc.Encode(v); err != nil {
136+ return nil, err
137+ }
138+ return bytes.TrimRight(b.Bytes(), "\n"), nil
139+}
140+
141+// parseObject reads a JSON object into its members, in order.
142+func parseObject(b []byte) ([]jsonMember, error) {
143+ dec := json.NewDecoder(bytes.NewReader(b))
144+ if t, err := dec.Token(); err != nil || t != json.Delim('{') {
145+ return nil, errors.New("not a JSON object")
146+ }
147+ var out []jsonMember
148+ for dec.More() {
149+ kt, err := dec.Token()
150+ if err != nil {
151+ return nil, err
152+ }
153+ k, ok := kt.(string)
154+ if !ok {
155+ return nil, errors.New("not a JSON object")
156+ }
157+ var v json.RawMessage
158+ if err := dec.Decode(&v); err != nil {
159+ return nil, err
160+ }
161+ out = append(out, jsonMember{k, v})
162+ }
163+ if _, err := dec.Token(); err != nil {
164+ return nil, err
165+ }
166+ if _, err := dec.Token(); err != io.EOF {
167+ return nil, errors.New("data after the JSON object")
168+ }
169+ return out, nil
170+}
171+
172+func encodeObject(ms []jsonMember) (json.RawMessage, error) {
173+ var b bytes.Buffer
174+ b.WriteByte('{')
175+ for i, m := range ms {
176+ if i > 0 {
177+ b.WriteByte(',')
178+ }
179+ k, err := marshalNoEscape(m.key)
180+ if err != nil {
181+ return nil, err
182+ }
183+ b.Write(k)
184+ b.WriteByte(':')
185+ b.Write(m.val)
186+ }
187+ b.WriteByte('}')
188+ return b.Bytes(), nil
189+}
A internal/host/guestforge_test.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "encoding/json"
7+ "strings"
8+ "testing"
9+
10+ ermite "bnema.dev/ermite-api/gen/ermite"
11+
12+ "bnema.dev/ermite-runner/internal/execstream"
13+ "bnema.dev/ermite-runner/internal/execstream/execpb"
14+)
15+
16+const publicContexts = `{"github":{"job":"","ref":"refs/heads/main","repository":"octo/hello","repositoryUrl":"git://ermite.bnema.dev/octo/hello.git",` +
17+ `"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev/-/github/api","graphql_url":"https://ermite.bnema.dev/-/github/api/graphql",` +
18+ `"event":{"compare":"https://ermite.bnema.dev/octo/hello/compare/a...b","repository":{"html_url":"https://ermite.bnema.dev/octo/hello"}},` +
19+ `"note":"a & <b>"},"needs":{},"vars":{"U":"https://ermite.bnema.dev/x"}}`
20+
21+func TestRewriteServerURLs(t *testing.T) {
22+ got, err := RewriteServerURLs(publicContexts, "http://ermite.bnema.dev:8088")
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ want := strings.NewReplacer(
27+ `"server_url":"https://ermite.bnema.dev"`, `"server_url":"http://ermite.bnema.dev:8088"`,
28+ `"api_url":"https://ermite.bnema.dev/-/github/api"`, `"api_url":"http://ermite.bnema.dev:8088/-/github/api"`,
29+ `"graphql_url":"https://ermite.bnema.dev/-/github/api/graphql"`, `"graphql_url":"http://ermite.bnema.dev:8088/-/github/api/graphql"`,
30+ ).Replace(publicContexts)
31+ // Only the three fields change: same bytes everywhere else (key order, the
32+ // event payload as recorded, other URLs, escaping).
33+ if got != want {
34+ t.Fatalf("got\n%s\nwant\n%s", got, want)
35+ }
36+ if !json.Valid([]byte(got)) {
37+ t.Fatal("invalid JSON")
38+ }
39+}
40+
41+func TestRewriteServerURLsMatchesOriginsOnly(t *testing.T) {
42+ const target = "http://forge.internal:8088"
43+ cases := map[string]struct {
44+ in string
45+ same bool
46+ }{
47+ "other origin is untouched": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://api.example.org/x","graphql_url":"https://ermite.bnema.dev:8443/g"}}`, false},
48+ "host prefix is not a match": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev.evil.test/x","graphql_url":"https://xermite.bnema.dev/g"}}`, false},
49+ "no server_url": {`{"github":{"api_url":"https://ermite.bnema.dev/-/github/api"}}`, true},
50+ "no github": {`{"vars":{"a":"b"}}`, true},
51+ "empty server_url": {`{"github":{"server_url":"","api_url":"https://ermite.bnema.dev/a"}}`, true},
52+ "non-string values": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":null,"graphql_url":3}}`, false},
53+ "already the target": {`{"github":{"server_url":"http://forge.internal:8088","api_url":"http://forge.internal:8088/a"}}`, true},
54+ "credentials are not origins": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://u:p@ermite.bnema.dev/a"}}`, false},
55+ }
56+ for name, c := range cases {
57+ got, err := RewriteServerURLs(c.in, target)
58+ if err != nil {
59+ t.Fatalf("%s: %v", name, err)
60+ }
61+ if c.same && got != c.in {
62+ t.Errorf("%s: changed:\n%s", name, got)
63+ }
64+ var v struct {
65+ GitHub map[string]any `json:"github"`
66+ }
67+ if err := json.Unmarshal([]byte(got), &v); err != nil {
68+ t.Fatalf("%s: %v", name, err)
69+ }
70+ switch name {
71+ case "other origin is untouched", "host prefix is not a match", "credentials are not origins":
72+ if v.GitHub["server_url"] != target {
73+ t.Errorf("%s: server_url %v", name, v.GitHub["server_url"])
74+ }
75+ if u, _ := v.GitHub["api_url"].(string); strings.HasPrefix(u, target) {
76+ t.Errorf("%s: api_url rewritten: %s", name, u)
77+ }
78+ if u, _ := v.GitHub["graphql_url"].(string); strings.HasPrefix(u, target) {
79+ t.Errorf("%s: graphql_url rewritten: %s", name, u)
80+ }
81+ case "non-string values":
82+ if v.GitHub["server_url"] != target || v.GitHub["api_url"] != nil || v.GitHub["graphql_url"] != float64(3) {
83+ t.Errorf("%s: %v", name, v.GitHub)
84+ }
85+ }
86+ }
87+}
88+
89+func TestRewriteServerURLsSpellings(t *testing.T) {
90+ // Case of scheme and host and a default port do not change the origin.
91+ in := `{"github":{"server_url":"HTTPS://Ermite.BNEMA.dev:443/","api_url":"https://ermite.bnema.dev/-/github/api?x=1#f"}}`
92+ got, err := RewriteServerURLs(in, "https://forge.test")
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ if got != `{"github":{"server_url":"https://forge.test/","api_url":"https://forge.test/-/github/api?x=1#f"}}` {
97+ t.Fatal(got)
98+ }
99+ // Off: the contexts are returned as received, valid or not.
100+ if got, err := RewriteServerURLs("not json", ""); err != nil || got != "not json" {
101+ t.Fatalf("%q %v", got, err)
102+ }
103+ for _, bad := range []string{"not json", `[1]`, `{"github":[1]}`, `{"github":{}} x`} {
104+ if _, err := RewriteServerURLs(bad, "http://a.test"); err == nil {
105+ t.Errorf("%q accepted", bad)
106+ }
107+ }
108+}
109+
110+func TestOriginOf(t *testing.T) {
111+ for in, want := range map[string]string{
112+ "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",
113+ "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",
114+ "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",
115+ } {
116+ if got := originOf(in); got != want {
117+ t.Errorf("originOf(%q) = %q, want %q", in, got, want)
118+ }
119+ }
120+}
121+
122+// The executor receives the rewritten contexts when the access is on, and the
123+// recorded ones when it is off.
124+func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) {
125+ for _, on := range []bool{true, false} {
126+ h := newHarness(t, simpleScript("x"))
127+ job := testJob("j1")
128+ job.Contexts = publicContexts
129+ h.srv.jobs = []*ermite.Job{job}
130+ if on {
131+ h.runner.Cfg.GuestForgeOrigin = "http://ermite.bnema.dev:8088"
132+ }
133+ var seen string
134+ inner := h.driver.script
135+ h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
136+ seen = run.Job.Contexts
137+ inner(t, c, run, cn)
138+ }
139+ h.run(t)
140+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
141+ if got := strings.Contains(seen, `"server_url":"http://ermite.bnema.dev:8088"`); got != on {
142+ t.Errorf("guest forge %v: contexts %s", on, seen)
143+ }
144+ if !on && seen != publicContexts {
145+ t.Errorf("contexts changed while off: %s", seen)
146+ }
147+ }
148+}
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "encoding/json"
7+ "strings"
8+ "testing"
9+
10+ ermite "bnema.dev/ermite-api/gen/ermite"
11+
12+ "bnema.dev/ermite-runner/internal/execstream"
13+ "bnema.dev/ermite-runner/internal/execstream/execpb"
14+)
15+
16+const publicContexts = `{"github":{"job":"","ref":"refs/heads/main","repository":"octo/hello","repositoryUrl":"git://ermite.bnema.dev/octo/hello.git",` +
17+ `"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev/-/github/api","graphql_url":"https://ermite.bnema.dev/-/github/api/graphql",` +
18+ `"event":{"compare":"https://ermite.bnema.dev/octo/hello/compare/a...b","repository":{"html_url":"https://ermite.bnema.dev/octo/hello"}},` +
19+ `"note":"a & <b>"},"needs":{},"vars":{"U":"https://ermite.bnema.dev/x"}}`
20+
21+func TestRewriteServerURLs(t *testing.T) {
22+ got, err := RewriteServerURLs(publicContexts, "http://ermite.bnema.dev:8088")
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ want := strings.NewReplacer(
27+ `"server_url":"https://ermite.bnema.dev"`, `"server_url":"http://ermite.bnema.dev:8088"`,
28+ `"api_url":"https://ermite.bnema.dev/-/github/api"`, `"api_url":"http://ermite.bnema.dev:8088/-/github/api"`,
29+ `"graphql_url":"https://ermite.bnema.dev/-/github/api/graphql"`, `"graphql_url":"http://ermite.bnema.dev:8088/-/github/api/graphql"`,
30+ ).Replace(publicContexts)
31+ // Only the three fields change: same bytes everywhere else (key order, the
32+ // event payload as recorded, other URLs, escaping).
33+ if got != want {
34+ t.Fatalf("got\n%s\nwant\n%s", got, want)
35+ }
36+ if !json.Valid([]byte(got)) {
37+ t.Fatal("invalid JSON")
38+ }
39+}
40+
41+func TestRewriteServerURLsMatchesOriginsOnly(t *testing.T) {
42+ const target = "http://forge.internal:8088"
43+ cases := map[string]struct {
44+ in string
45+ same bool
46+ }{
47+ "other origin is untouched": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://api.example.org/x","graphql_url":"https://ermite.bnema.dev:8443/g"}}`, false},
48+ "host prefix is not a match": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://ermite.bnema.dev.evil.test/x","graphql_url":"https://xermite.bnema.dev/g"}}`, false},
49+ "no server_url": {`{"github":{"api_url":"https://ermite.bnema.dev/-/github/api"}}`, true},
50+ "no github": {`{"vars":{"a":"b"}}`, true},
51+ "empty server_url": {`{"github":{"server_url":"","api_url":"https://ermite.bnema.dev/a"}}`, true},
52+ "non-string values": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":null,"graphql_url":3}}`, false},
53+ "already the target": {`{"github":{"server_url":"http://forge.internal:8088","api_url":"http://forge.internal:8088/a"}}`, true},
54+ "credentials are not origins": {`{"github":{"server_url":"https://ermite.bnema.dev","api_url":"https://u:p@ermite.bnema.dev/a"}}`, false},
55+ }
56+ for name, c := range cases {
57+ got, err := RewriteServerURLs(c.in, target)
58+ if err != nil {
59+ t.Fatalf("%s: %v", name, err)
60+ }
61+ if c.same && got != c.in {
62+ t.Errorf("%s: changed:\n%s", name, got)
63+ }
64+ var v struct {
65+ GitHub map[string]any `json:"github"`
66+ }
67+ if err := json.Unmarshal([]byte(got), &v); err != nil {
68+ t.Fatalf("%s: %v", name, err)
69+ }
70+ switch name {
71+ case "other origin is untouched", "host prefix is not a match", "credentials are not origins":
72+ if v.GitHub["server_url"] != target {
73+ t.Errorf("%s: server_url %v", name, v.GitHub["server_url"])
74+ }
75+ if u, _ := v.GitHub["api_url"].(string); strings.HasPrefix(u, target) {
76+ t.Errorf("%s: api_url rewritten: %s", name, u)
77+ }
78+ if u, _ := v.GitHub["graphql_url"].(string); strings.HasPrefix(u, target) {
79+ t.Errorf("%s: graphql_url rewritten: %s", name, u)
80+ }
81+ case "non-string values":
82+ if v.GitHub["server_url"] != target || v.GitHub["api_url"] != nil || v.GitHub["graphql_url"] != float64(3) {
83+ t.Errorf("%s: %v", name, v.GitHub)
84+ }
85+ }
86+ }
87+}
88+
89+func TestRewriteServerURLsSpellings(t *testing.T) {
90+ // Case of scheme and host and a default port do not change the origin.
91+ in := `{"github":{"server_url":"HTTPS://Ermite.BNEMA.dev:443/","api_url":"https://ermite.bnema.dev/-/github/api?x=1#f"}}`
92+ got, err := RewriteServerURLs(in, "https://forge.test")
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ if got != `{"github":{"server_url":"https://forge.test/","api_url":"https://forge.test/-/github/api?x=1#f"}}` {
97+ t.Fatal(got)
98+ }
99+ // Off: the contexts are returned as received, valid or not.
100+ if got, err := RewriteServerURLs("not json", ""); err != nil || got != "not json" {
101+ t.Fatalf("%q %v", got, err)
102+ }
103+ for _, bad := range []string{"not json", `[1]`, `{"github":[1]}`, `{"github":{}} x`} {
104+ if _, err := RewriteServerURLs(bad, "http://a.test"); err == nil {
105+ t.Errorf("%q accepted", bad)
106+ }
107+ }
108+}
109+
110+func TestOriginOf(t *testing.T) {
111+ for in, want := range map[string]string{
112+ "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",
113+ "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",
114+ "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",
115+ } {
116+ if got := originOf(in); got != want {
117+ t.Errorf("originOf(%q) = %q, want %q", in, got, want)
118+ }
119+ }
120+}
121+
122+// The executor receives the rewritten contexts when the access is on, and the
123+// recorded ones when it is off.
124+func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) {
125+ for _, on := range []bool{true, false} {
126+ h := newHarness(t, simpleScript("x"))
127+ job := testJob("j1")
128+ job.Contexts = publicContexts
129+ h.srv.jobs = []*ermite.Job{job}
130+ if on {
131+ h.runner.Cfg.GuestForgeOrigin = "http://ermite.bnema.dev:8088"
132+ }
133+ var seen string
134+ inner := h.driver.script
135+ h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) {
136+ seen = run.Job.Contexts
137+ inner(t, c, run, cn)
138+ }
139+ h.run(t)
140+ eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
141+ if got := strings.Contains(seen, `"server_url":"http://ermite.bnema.dev:8088"`); got != on {
142+ t.Errorf("guest forge %v: contexts %s", on, seen)
143+ }
144+ if !on && seen != publicContexts {
145+ t.Errorf("contexts changed while off: %s", seen)
146+ }
147+ }
148+}
M internal/host/job.go +10 -1
@@ -67,6 +67,9 @@ type jobRun struct {
6767 maxStarted int32
6868 lastLogStep int32
6969 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
7073 // hard stops the sandbox at once; set before the session starts.
7174 hard func()
7275 result *execpb.JobResult
@@ -562,11 +565,17 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL
562565
563566 // execute starts the sandbox, runs the job in it and returns how it ended.
564567 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+ }
565573 sb, err := rt.Driver.New()
566574 if err != nil {
567575 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))
568576 return failure()
569577 }
578+
570579 // Stop runs to completion even when the job context is done.
571580 defer j.stopSandbox(ctx, rt, sb)
572581
@@ -632,7 +641,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo
632641 func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
633642 run := &execpb.Run{Job: &execpb.JobSpec{
634643 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635- Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
644+ Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636645 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637646 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638647 }}
@@ -67,6 +67,9 @@ type jobRun struct {
67 maxStarted int3267 maxStarted int32
68 lastLogStep int3268 lastLogStep int32
69 violation string69 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.JobResult75 result *execpb.JobResult
@@ -562,11 +565,17 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL
562 565
563 // execute starts the sandbox, runs the job in it and returns how it ended.566 // execute starts the sandbox, runs the job in it and returns how it ended.
564 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {567 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {
568+ var err error
569+ if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil {
570+ j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
571+ return failure()
572+ }
565 sb, err := rt.Driver.New()573 sb, err := rt.Driver.New()
566 if err != nil {574 if err != nil {
567 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))575 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))
568 return failure()576 return failure()
569 }577 }
578+
570 // Stop runs to completion even when the job context is done.579 // Stop runs to completion even when the job context is done.
571 defer j.stopSandbox(ctx, rt, sb)580 defer j.stopSandbox(ctx, rt, sb)
572 581
@@ -632,7 +641,7 @@ func (j *jobRun) startSandbox(ctx context.Context, rt *Runner, sb sandbox.Sandbo
632 func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {641 func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
633 run := &execpb.Run{Job: &execpb.JobSpec{642 run := &execpb.Run{Job: &execpb.JobSpec{
634 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,643 WorkflowPath: j.job.WorkflowPath, WorkflowYaml: j.job.WorkflowYaml, JobKey: j.job.JobKey,
635- Matrix: j.job.Matrix, Contexts: j.job.Contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),644+ Matrix: j.job.Matrix, Contexts: j.contexts, Repo: j.job.Repo, TimeoutMinutes: int32(j.timeoutMinutes()),
636 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),645 DeadlineMs: max(1, time.Until(j.deadline()).Milliseconds()),
637 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),646 RunnerName: rt.Cfg.Name, RunnerVersion: rt.Cfg.Version, RunnerEnvironment: rt.Cfg.environment(),
638 }}647 }}
M internal/sandbox/config.go +14 -0
@@ -108,6 +108,13 @@ type Config struct {
108108 DenyPrivileged bool
109109 // LockFile is the host-wide single-instance lock.
110110 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
111118 // Rate limits of micro-VMs; zero disables.
112119 DiskBandwidthMiB, DiskIOPS, NetMbit int
113120 }
@@ -189,6 +196,13 @@ func LoadConfig(getenv func(string) string) (Config, error) {
189196 default:
190197 return c, fmt.Errorf("%s: %q is not 0 or 1", EnvDenyPrivileged, getenv(EnvDenyPrivileged))
191198 }
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+ }
192206 if v := getenv(EnvLockFile); v != "" {
193207 if !filepath.IsAbs(v) {
194208 return c, fmt.Errorf("%s: %q must be an absolute path", EnvLockFile, v)
@@ -108,6 +108,13 @@ type Config struct {
108 DenyPrivileged bool108 DenyPrivileged bool
109 // LockFile is the host-wide single-instance lock.109 // LockFile is the host-wide single-instance lock.
110 LockFile string110 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 int119 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) {
10141014 // GO-002: only a podman daemon gets a DNS listener that may precede the gateway
10151015 // address; Docker keeps a normal bind.
10161016 func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) {
1017- if newNet(nil, 0, false).DNS.FreeBind {
1017+ if newNet(nil, 0, false, "").DNS.FreeBind {
10181018 t.Fatal("Docker must keep a normal bind")
10191019 }
1020- if !newNet(nil, 0, true).DNS.FreeBind {
1020+ if !newNet(nil, 0, true, "").DNS.FreeBind {
10211021 t.Fatal("podman needs the listener before the bridge address")
10221022 }
10231023 }
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 gateway1014 // 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)
163163 }
164164 d.net = opt.Net
165165 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())
167167 }
168168 if err := d.net.EnableForwarding(); err != nil {
169169 return fail(err)
170170 }
171+ if cfg.GuestForge != nil {
172+ log.Warn().Msg(cfg.GuestForge.Warning())
173+ }
171174 return d, nil
172175 }
173176
174177 // newNet builds the host network plumbing. Only podman creates the bridge
175178 // address late (see daemonInfo.Podman), so only there the DNS listener may come
176179 // 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 {
178181 n := netsetup.New(run, dnsPort, "")
179182 n.DNS.FreeBind = podman
183+ n.DNS.Override = forgeHost
180184 return n
181185 }
182186
@@ -302,6 +306,9 @@ func (s *containerSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (san
302306 // operator's logs must say what kind of isolation this job got.
303307 log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(privilegedWarning)
304308 }
309+ if d.cfg.GuestForge != nil {
310+ log.Warn().Str("job", spec.JobID).Str("container", s.id).Msg(d.cfg.GuestForge.Warning())
311+ }
305312 conn, err := s.run(bootCtx, spec)
306313 if err != nil {
307314 var tail string
@@ -421,7 +428,7 @@ func (s *containerSandbox) runNetwork(ctx context.Context, netw netpolicy.Net, l
421428 }
422429 s.netID = netID
423430 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()})
425432 }
426433
427434 // 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.Net164 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, nil174 return d, nil
172 }175 }
173 176
174 // newNet builds the host network plumbing. Only podman creates the bridge177 // newNet builds the host network plumbing. Only podman creates the bridge
175 // address late (see daemonInfo.Podman), so only there the DNS listener may come178 // 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 = podman182 n.DNS.FreeBind = podman
183+ n.DNS.Override = forgeHost
180 return n184 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 string314 var tail string
@@ -421,7 +428,7 @@ func (s *containerSandbox) runNetwork(ctx context.Context, netw netpolicy.Net, l
421 }428 }
422 s.netID = netID429 s.netID = netID
423 s.attached = true430 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)
147147 }
148148 d.net = opt.Net
149149 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())
151157 }
152158 if err := d.net.EnableForwarding(); err != nil {
153159 return fail(err)
@@ -266,6 +272,10 @@ func (s *vmSandbox) Start(ctx context.Context, spec sandbox.JobSpec) (sandbox.Co
266272 log := zerowrap.FromCtx(ctx)
267273 d := s.d
268274 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+ }
269279
270280 jailed := !d.cfg.JailerOff
271281 runUID := s.uid()
@@ -338,7 +348,7 @@ func (s *vmSandbox) bootNetwork(ctx context.Context, netw netpolicy.Net) error {
338348 return err
339349 }
340350 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()})
342352 }
343353
344354 // 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.Net148 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.d273 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.JailerOff280 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 err348 return err
339 }349 }
340 s.attached = true // from here Detach is idempotent and always safe350 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) {
13071307 t.Fatal("an unkillable process was reported gone")
13081308 }
13091309 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close