1// SPDX-License-Identifier: MIT
2
3package sandbox
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "net"
10 "net/netip"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15)
16
17// EnvGuestForge is ERMITE_RUNNER_GUEST_FORGE=1: sandboxes reach the forge the
18// way the runner does, that is at the host and port of ERMITE_RUNNER_URL, on
19// the host. For staging hosts where the forge is not reachable from the
20// internet side of the NAT (see the README). Off by default.
21const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE"
22
23// EnvGuestForgeTarget is ERMITE_RUNNER_GUEST_FORGE_TARGET=host:port: with the
24// guest forge access on, the runner relays the forge port of each sandbox's
25// gateway to that address instead of letting the host's own listener answer.
26// For a runner in a container, where the forge is another container reached
27// by name (ermite:7341) and nothing listens on the forge port locally.
28const EnvGuestForgeTarget = "ERMITE_RUNNER_GUEST_FORGE_TARGET"
29
30// GuestForge is the one forge endpoint that sandboxes may reach on the host.
31// It is derived from the server URL by ParseGuestForge.
32type GuestForge struct {
33 // Host is the lower-case DNS name of the forge, as in the URL. The DNS
34 // forwarder answers it with the gateway address of each sandbox.
35 Host string
36 // Port is the TCP port: the only host port a sandbox may reach.
37 Port uint16
38 // Origin is scheme://host[:port] of the URL: the jobs' server URLs are
39 // rewritten to it.
40 Origin string
41 // Target, when not empty, is the host:port the runner relays the sandboxes'
42 // forge connections to (EnvGuestForgeTarget).
43 Target string
44}
45
46// RelayTarget is Target, "" when the access is off or not relayed. Safe on nil.
47func (g *GuestForge) RelayTarget() string {
48 if g == nil {
49 return ""
50 }
51 return g.Target
52}
53
54// ParseGuestForgeTarget checks a relay target: host:port with a host name or
55// an IP address and a port in 1-65535. "" is accepted (no relay).
56func ParseGuestForgeTarget(raw string) (string, error) {
57 raw = strings.TrimSpace(raw)
58 if raw == "" {
59 return "", nil
60 }
61 h, p, err := net.SplitHostPort(raw)
62 if err != nil || h == "" {
63 return "", fmt.Errorf("%s: %q is not host:port", EnvGuestForgeTarget, raw)
64 }
65 if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 {
66 return "", fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForgeTarget, p)
67 }
68 if _, err := netip.ParseAddr(h); err != nil {
69 if err := CheckForgeHostname(strings.ToLower(h)); err != nil {
70 return "", fmt.Errorf("%s: %w", EnvGuestForgeTarget, err)
71 }
72 }
73 return raw, nil
74}
75
76// Warning is the line logged at start and for each job.
77func (g *GuestForge) Warning() string {
78 return "guest forge access: sandboxes may reach " + g.Origin + " on the host"
79}
80
81// DNSName is the overridden name, "" when the access is off. Safe on nil.
82func (g *GuestForge) DNSName() string {
83 if g == nil {
84 return ""
85 }
86 return g.Host
87}
88
89// TCPPort is the allowed port, 0 when the access is off. Safe on nil.
90func (g *GuestForge) TCPPort() uint16 {
91 if g == nil {
92 return 0
93 }
94 return g.Port
95}
96
97// ParseGuestForge derives the endpoint from the server URL. The URL must have
98// a DNS host name (an IP literal cannot be redirected by the DNS forwarder),
99// no path, query or credentials, and the port defaults from the scheme (443,
100// 80). A plain http URL is accepted only with insecureHTTP (ERMITE_RUNNER_
101// INSECURE_HTTP=1): the job tokens then cross the host in clear, and that must
102// be asked for explicitly even for localhost.
103func ParseGuestForge(raw string, insecureHTTP bool) (*GuestForge, error) {
104 u, err := url.Parse(raw)
105 if err != nil || u.Host == "" {
106 return nil, fmt.Errorf("%s: the server URL %q is not a valid URL", EnvGuestForge, raw)
107 }
108 if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
109 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)
110 }
111 var def uint16
112 switch u.Scheme {
113 case "https":
114 def = 443
115 case "http":
116 def = 80
117 if !insecureHTTP {
118 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)
119 }
120 default:
121 return nil, fmt.Errorf("%s: the server URL must be http or https", EnvGuestForge)
122 }
123 host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
124 if err := CheckForgeHostname(host); err != nil {
125 return nil, fmt.Errorf("%s: %w", EnvGuestForge, err)
126 }
127 port := def
128 if p := u.Port(); p != "" {
129 n, err := strconv.Atoi(p)
130 if err != nil || n < 1 || n > 65535 {
131 return nil, fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForge, p)
132 }
133 port = uint16(n)
134 }
135 origin := u.Scheme + "://" + host
136 if u.Port() != "" {
137 origin += ":" + u.Port()
138 }
139 return &GuestForge{Host: host, Port: port, Origin: origin}, nil
140}
141
142// CheckForgeHostname accepts one DNS host name: dot separated labels of ASCII
143// letters, digits and hyphens (not at either end), at most 63 bytes each and
144// 253 in all. IP literals, wildcards, underscores, IDNs and empty labels are
145// refused.
146func CheckForgeHostname(h string) error {
147 if h == "" || len(h) > 253 {
148 return errors.New("the host name is empty or longer than 253 bytes")
149 }
150 if _, err := netip.ParseAddr(h); err == nil {
151 return fmt.Errorf("%q is an IP address: a host name is required (sandboxes are pointed at the forge by DNS)", h)
152 }
153 labels := strings.Split(h, ".")
154 allNumeric := true
155 for _, l := range labels {
156 if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
157 return fmt.Errorf("%q is not a valid host name", h)
158 }
159 numeric := true
160 for _, r := range l {
161 ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-'
162 if !ok {
163 return fmt.Errorf("%q is not a valid host name (wildcards, underscores and non-ASCII are refused)", h)
164 }
165 if r < '0' || r > '9' {
166 numeric = false
167 }
168 }
169 allNumeric = allNumeric && numeric
170 }
171 if allNumeric {
172 // "10.1" or "2130706433": legacy spellings of an address.
173 return fmt.Errorf("%q looks like an IP address: a host name is required", h)
174 }
175 return nil
176}
177
178// ResolveGuestForge is ParseGuestForge plus a check that the host name
179// resolves on the host (lookup is net.DefaultResolver.LookupHost in
180// production): the runner reaches the forge by that name, so a name that
181// resolves to nothing is a configuration error to report at start.
182//
183// target is the relay target (EnvGuestForgeTarget, "" for none). With a
184// target, the sandboxes still see the forge under the host name of raw, and it
185// is the target's host that must resolve: the runner connects there.
186func ResolveGuestForge(ctx context.Context, raw, target string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) {
187 g, err := ParseGuestForge(raw, insecureHTTP)
188 if err != nil {
189 return nil, err
190 }
191 if g.Target, err = ParseGuestForgeTarget(target); err != nil {
192 return nil, err
193 }
194 host := g.Host
195 if g.Target != "" {
196 host, _, _ = net.SplitHostPort(g.Target)
197 if _, err := netip.ParseAddr(host); err == nil {
198 return g, nil
199 }
200 }
201 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
202 defer cancel()
203 addrs, err := lookup(ctx, host)
204 if err != nil {
205 return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, host, err)
206 }
207 if len(addrs) == 0 {
208 return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, host)
209 }
210 return g, nil
211}