1// SPDX-License-Identifier: MIT
2
3package sandbox
4
5import (
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.
20const 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.
24type 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.
36func (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.
41func (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.
49func (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.
62func 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.
105func 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.
141func 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}