v0.6.0 aba298d

guestforge.go RawHistoryFolder

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}

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