v0.36.0 1fb2421

config.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package config parses and validates the server configuration from the
4// environment. It is a composition-root helper: only internal/app imports it, and it never
5// reads the process environment itself, the caller passes a lookup function.
6package config
7
8import (
9	"crypto/sha256"
10	"encoding/base64"
11	"fmt"
12	"net"
13	"net/netip"
14	"net/url"
15	"os"
16	"path/filepath"
17	"strconv"
18	"strings"
19	"time"
20	"unicode/utf8"
21
22	"bnema.dev/ermite/internal/mediapolicy"
23)
24
25// Variable names.
26const (
27	EnvVideoHosts     = "ERMITE_VIDEO_HOSTS"
28	EnvAddr           = "ERMITE_ADDR"
29	EnvDataDir        = "ERMITE_DATA_DIR"
30	EnvPublicURL      = "ERMITE_PUBLIC_URL"
31	EnvAdminToken     = "ERMITE_ADMIN_TOKEN"
32	EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
33	EnvGit            = "ERMITE_GIT"
34	EnvGitReader      = "ERMITE_GIT_READER"
35	EnvSourceURL      = "ERMITE_SOURCE_URL"
36	EnvLogLevel       = "ERMITE_LOG_LEVEL"
37	EnvLogFormat      = "ERMITE_LOG_FORMAT"
38	// EnvCommitterName and EnvCommitterEmail name the committer of the
39	// commits ermite creates (squash merges, ADR 0022).
40	EnvCommitterName  = "ERMITE_COMMITTER_NAME"
41	EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
42	// EnvOwner is the owner login CI shows in the github context
43	// ("<owner>/<repo>"); it follows the rules of repository names.
44	EnvOwner = "ERMITE_OWNER"
45	// EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
46	EnvCILogRetention = "ERMITE_CI_LOG_RETENTION"
47	// EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
48	// when a reverse proxy cuts idle responses sooner than the default 50 s.
49	EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"
50	// EnvSecretsKey is the master key that encrypts CI secrets at rest: 32
51	// random bytes in base64, provided by the deployment secret store like
52	// ERMITE_ADMIN_TOKEN. Without it secrets cannot be set and jobs get none.
53	EnvSecretsKey = "ERMITE_SECRETS_KEY"
54	// EnvCIArtifacts turns the CI artifacts (artifacts v4) off when "0" or
55	// "false"; they are on by default.
56	EnvCIArtifacts = "ERMITE_CI_ARTIFACTS"
57	// EnvCIArtifactRetention is the default and longest retention of an
58	// artifact, EnvCIArtifactQuota the space of the artifacts of one
59	// repository, EnvCIArtifactMax the size of one artifact.
60	EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"
61	EnvCIArtifactQuota     = "ERMITE_CI_ARTIFACT_QUOTA"
62	EnvCIArtifactMax       = "ERMITE_CI_ARTIFACT_MAX"
63	EnvS3Bucket            = "ERMITE_S3_BUCKET"
64	EnvS3Endpoint          = "ERMITE_S3_ENDPOINT"
65	EnvS3Region            = "ERMITE_S3_REGION"
66	EnvS3AccessKeyID       = "ERMITE_S3_ACCESS_KEY_ID"
67	EnvS3SecretAccessKey   = "ERMITE_S3_SECRET_ACCESS_KEY"
68	EnvS3ForcePathStyle    = "ERMITE_S3_FORCE_PATH_STYLE"
69	EnvS3Prefix            = "ERMITE_S3_PREFIX"
70	EnvReleaseAssetMax     = "ERMITE_RELEASE_ASSET_MAX"
71	EnvReleaseAssetsMax    = "ERMITE_RELEASE_ASSETS_MAX"
72	EnvReleaseQuota        = "ERMITE_RELEASE_QUOTA"
73)
74
75// SecretsKeyLen is the length of the secrets key in bytes (AES-256).
76const SecretsKeyLen = 32
77
78// Defaults.
79const (
80	DefaultAddr      = ":7341"
81	DefaultGit       = "git"
82	DefaultGitReader = GitReaderErmiteGit
83	DefaultLogLevel  = "info"
84	DefaultLogFormat = "json"
85	// DefaultCommitterName is the committer name of the commits ermite creates.
86	DefaultCommitterName = "ermite"
87	// DefaultOwner is the owner login when ERMITE_OWNER is not set.
88	DefaultOwner = "owner"
89)
90
91// Values of ERMITE_GIT_READER.
92const (
93	// GitReaderCLI reads repositories with the git binary only.
94	GitReaderCLI = "cli"
95	// GitReaderErmiteGit reads them with the ermite-git library where it is
96	// proven faster, and with the git binary elsewhere.
97	GitReaderErmiteGit = "ermite-git"
98)
99
100// Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s
101// that the runner waits for one FetchJob; the lower one stops runners from
102// polling in a tight loop.
103const (
104	MinCIPollTimeout = time.Second
105	MaxCIPollTimeout = 55 * time.Second
106)
107
108// MinAdminTokenLen is the shortest accepted admin token, in characters.
109const MinAdminTokenLen = 32
110
111// Config is the validated server configuration.
112type Config struct {
113	// VideoHosts are exact external hosts allowed to load README videos.
114	VideoHosts []string
115	// Addr is the listen address, "host:port".
116	Addr string
117	// DataDir is the absolute, clean data root.
118	DataDir string
119	// PublicURL is the base URL without a trailing slash.
120	PublicURL string
121	// AdminTokenHash is the SHA-256 of the admin token. The token itself is
122	// dropped; compare with a constant-time function.
123	AdminTokenHash [sha256.Size]byte
124	// TrustedProxies are the networks allowed to set X-Forwarded-For.
125	TrustedProxies []netip.Prefix
126	// Git is the git executable, a name or a path.
127	Git string
128	// GitReader is GitReaderCLI or GitReaderErmiteGit.
129	GitReader string
130	// SourceURL is the link to the source of the running version.
131	SourceURL string
132	// LogLevel is one of trace, debug, info, warn, error.
133	LogLevel string
134	// LogFormat is json or console.
135	LogFormat string
136	// CommitterName and CommitterEmail are recorded as the committer of the
137	// commits ermite creates. The email defaults to "ermite@<host of
138	// PublicURL>".
139	CommitterName, CommitterEmail string
140	// Owner is the owner login of the github context of CI runs.
141	Owner string
142	// CIRetention is how long CI data is kept; 0 (variable unset) leaves the
143	// default to the CI use case.
144	CIRetention time.Duration
145	// CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
146	// leaves the default to the CI use case.
147	CIPollTimeout time.Duration
148	// SecretsKey is the decoded ERMITE_SECRETS_KEY (SecretsKeyLen bytes); nil
149	// when the variable is unset. It is a secret: never log it or put it in an
150	// error.
151	SecretsKey []byte
152	// CIArtifacts is false when ERMITE_CI_ARTIFACTS turns the artifacts off.
153	CIArtifacts bool
154	// CIArtifactRetention, CIArtifactQuota and CIArtifactMax are the limits of
155	// the artifacts; 0 (variable unset) leaves the default to the CI use case.
156	CIArtifactRetention time.Duration
157	CIArtifactQuota     int64
158	CIArtifactMax       int64
159	// S3Bucket selects S3; empty selects local disk. Credentials are secrets.
160	S3Bucket, S3Endpoint, S3Region, S3AccessKeyID, S3SecretAccessKey, S3Prefix string
161	S3ForcePathStyle                                                           bool
162	// Zero release limits leave defaults to the release use case.
163	ReleaseAssetMax, ReleaseQuota int64
164	ReleaseAssetsMax              int
165}
166
167// Error lists every configuration problem found, in variable order.
168type Error struct {
169	Problems []string
170}
171
172// Error implements error.
173func (e *Error) Error() string {
174	var b strings.Builder
175	b.WriteString("invalid configuration:")
176	for _, p := range e.Problems {
177		b.WriteString("\n  - ")
178		b.WriteString(p)
179	}
180	return b.String()
181}
182
183// Load reads the configuration through lookup, which returns the value of a
184// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
185// unset. All problems are reported together in an *Error. Messages name the
186// variable and never contain the admin token.
187func Load(get func(string) string) (Config, error) {
188	var problems []string
189	bad := func(key, format string, args ...any) {
190		problems = append(problems, key+": "+fmt.Sprintf(format, args...))
191	}
192	get = resolveFiles(get, &problems)
193	var c Config
194	var hostErr error
195	c.VideoHosts, hostErr = mediapolicy.Parse(get(EnvVideoHosts))
196	if hostErr != nil {
197		bad(EnvVideoHosts, "%v", hostErr)
198	}
199
200	c.Addr = get(EnvAddr)
201	if c.Addr == "" {
202		c.Addr = DefaultAddr
203	} else if err := validateAddr(c.Addr); err != nil {
204		bad(EnvAddr, "%v", err)
205	}
206
207	switch dir := get(EnvDataDir); {
208	case dir == "":
209		bad(EnvDataDir, "required")
210	case !filepath.IsAbs(dir):
211		bad(EnvDataDir, "must be an absolute path")
212	default:
213		c.DataDir = filepath.Clean(dir)
214	}
215
216	publicOK := false
217	if raw := get(EnvPublicURL); raw == "" {
218		bad(EnvPublicURL, "required")
219	} else if u, err := parseURL(raw, false); err != nil {
220		bad(EnvPublicURL, "%v", err)
221	} else {
222		c.PublicURL = u
223		publicOK = true
224	}
225
226	if token := get(EnvAdminToken); token == "" {
227		bad(EnvAdminToken, "required")
228	} else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
229		bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
230	} else {
231		c.AdminTokenHash = sha256.Sum256([]byte(token))
232	}
233
234	if raw := get(EnvTrustedProxies); raw != "" {
235		prefixes, err := parseCIDRs(raw)
236		if err != nil {
237			bad(EnvTrustedProxies, "%v", err)
238		}
239		c.TrustedProxies = prefixes
240	}
241
242	c.Git = get(EnvGit)
243	if c.Git == "" {
244		c.Git = DefaultGit
245	}
246
247	c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
248	switch c.GitReader {
249	case "":
250		c.GitReader = DefaultGitReader
251	case GitReaderCLI, GitReaderErmiteGit:
252	default:
253		bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
254	}
255
256	switch raw := get(EnvSourceURL); {
257	case raw != "":
258		if u, err := parseURL(raw, true); err != nil {
259			bad(EnvSourceURL, "%v", err)
260		} else {
261			c.SourceURL = u
262		}
263	case publicOK:
264		c.SourceURL = c.PublicURL + "/ermite"
265	}
266
267	c.LogLevel = strings.ToLower(get(EnvLogLevel))
268	switch c.LogLevel {
269	case "":
270		c.LogLevel = DefaultLogLevel
271	case "trace", "debug", "info", "warn", "error":
272	default:
273		bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
274	}
275
276	c.LogFormat = strings.ToLower(get(EnvLogFormat))
277	switch c.LogFormat {
278	case "":
279		c.LogFormat = DefaultLogFormat
280	case "json", "console":
281	default:
282		bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
283	}
284
285	c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
286	if c.CommitterName == "" {
287		c.CommitterName = DefaultCommitterName
288	} else if err := validateIdentity(c.CommitterName); err != nil {
289		bad(EnvCommitterName, "%v", err)
290	}
291	c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
292	switch {
293	case c.CommitterEmail != "":
294		if err := validateIdentity(c.CommitterEmail); err != nil {
295			bad(EnvCommitterEmail, "%v", err)
296		} else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
297			bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
298		}
299	case publicOK:
300		u, _ := url.Parse(c.PublicURL)
301		c.CommitterEmail = "ermite@" + u.Hostname()
302	}
303
304	// The name rules of the owner are those of repository names (the domain);
305	// config imports nothing, so internal/app checks them (see loadConfig).
306	c.Owner = strings.TrimSpace(get(EnvOwner))
307	if c.Owner == "" {
308		c.Owner = DefaultOwner
309	}
310
311	if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" {
312		d, err := parseRetention(raw)
313		if err != nil {
314			bad(EnvCILogRetention, "%v", err)
315		} else {
316			c.CIRetention = d
317		}
318	}
319
320	if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" {
321		d, err := time.ParseDuration(raw)
322		switch {
323		case err != nil:
324			bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw)
325		case d < MinCIPollTimeout || d > MaxCIPollTimeout:
326			bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw)
327		default:
328			c.CIPollTimeout = d
329		}
330	}
331
332	if raw := strings.TrimSpace(get(EnvSecretsKey)); raw != "" {
333		// The message never repeats the value.
334		if key, ok := decodeSecretsKey(raw); !ok {
335			bad(EnvSecretsKey, "must be %d random bytes in base64 (for example `head -c 32 /dev/urandom | base64`)", SecretsKeyLen)
336		} else {
337			c.SecretsKey = key
338		}
339	}
340
341	switch raw := strings.ToLower(strings.TrimSpace(get(EnvCIArtifacts))); raw {
342	case "", "1", "true":
343		c.CIArtifacts = true
344	case "0", "false":
345	default:
346		bad(EnvCIArtifacts, "must be 0, 1, false or true (got %q)", raw)
347	}
348	if raw := strings.TrimSpace(get(EnvCIArtifactRetention)); raw != "" {
349		d, err := parseRetention(raw)
350		switch {
351		case err != nil:
352			bad(EnvCIArtifactRetention, "%v", err)
353		case d < 24*time.Hour:
354			bad(EnvCIArtifactRetention, "must be at least one day (got %q)", raw)
355		default:
356			c.CIArtifactRetention = d
357		}
358	}
359	for _, v := range []struct {
360		env string
361		dst *int64
362	}{{EnvCIArtifactQuota, &c.CIArtifactQuota}, {EnvCIArtifactMax, &c.CIArtifactMax}} {
363		if raw := strings.TrimSpace(get(v.env)); raw != "" {
364			n, err := parseSize(raw)
365			if err != nil {
366				bad(v.env, "%v", err)
367			} else {
368				*v.dst = n
369			}
370		}
371	}
372
373	loadStorage(get, &c, &problems)
374	if len(problems) > 0 {
375		return Config{}, &Error{Problems: problems}
376	}
377	return c, nil
378}
379
380// resolveFiles resolves only the four supported secrets, once at startup.
381// Neither file paths, file errors nor secret contents enter diagnostics.
382func resolveFiles(get func(string) string, problems *[]string) func(string) string {
383	values := make(map[string]string)
384	for _, key := range []string{EnvAdminToken, EnvSecretsKey, EnvS3AccessKeyID, EnvS3SecretAccessKey} {
385		value, file := get(key), get(key+"_FILE")
386		if value != "" && file != "" {
387			*problems = append(*problems, key+": cannot set both variable and _FILE")
388		} else if file != "" {
389			data, err := os.ReadFile(file)
390			if err != nil {
391				*problems = append(*problems, key+"_FILE: cannot read secret file")
392			} else {
393				value = strings.TrimRight(string(data), "\r\n")
394				if value == "" {
395					*problems = append(*problems, key+"_FILE: secret file is empty")
396				}
397			}
398		}
399		values[key] = value
400	}
401	return func(key string) string {
402		if value, ok := values[key]; ok {
403			return value
404		}
405		return get(key)
406	}
407}
408
409func loadStorage(get func(string) string, c *Config, problems *[]string) {
410	bad := func(key, message string) { *problems = append(*problems, key+": "+message) }
411	c.S3Bucket = get(EnvS3Bucket)
412	c.S3Endpoint = get(EnvS3Endpoint)
413	c.S3Region = get(EnvS3Region)
414	c.S3AccessKeyID = get(EnvS3AccessKeyID)
415	c.S3SecretAccessKey = get(EnvS3SecretAccessKey)
416	c.S3Prefix = strings.Trim(get(EnvS3Prefix), "/")
417	if c.S3Prefix != "" {
418		valid := len(c.S3Prefix) <= 512
419		for _, part := range strings.Split(c.S3Prefix, "/") {
420			if part == "" || part == "." || part == ".." {
421				valid = false
422			}
423		}
424		for _, r := range c.S3Prefix {
425			if r < 0x21 || r > 0x7e || r == '\\' {
426				valid = false
427			}
428		}
429		if !valid {
430			bad(EnvS3Prefix, "must be an ASCII key prefix without empty, dot or dot-dot segments")
431		}
432		c.S3Prefix += "/"
433	}
434	if c.S3Endpoint != "" {
435		if endpoint, err := parseURL(c.S3Endpoint, false); err != nil {
436			bad(EnvS3Endpoint, "must be an http(s) URL without credentials, path, query or fragment")
437		} else {
438			c.S3Endpoint = endpoint
439		}
440	}
441	switch get(EnvS3ForcePathStyle) {
442	case "", "0", "false":
443	case "1", "true":
444		c.S3ForcePathStyle = true
445	default:
446		bad(EnvS3ForcePathStyle, "must be 0, 1, false or true")
447	}
448	if c.S3Bucket != "" {
449		for _, v := range []struct{ key, value string }{{EnvS3Region, c.S3Region}, {EnvS3AccessKeyID, c.S3AccessKeyID}, {EnvS3SecretAccessKey, c.S3SecretAccessKey}} {
450			if strings.TrimSpace(v.value) == "" {
451				bad(v.key, "required when ERMITE_S3_BUCKET is set")
452			}
453		}
454	}
455	for _, v := range []struct {
456		key string
457		dst *int64
458	}{{EnvReleaseAssetMax, &c.ReleaseAssetMax}, {EnvReleaseQuota, &c.ReleaseQuota}} {
459		if raw := strings.TrimSpace(get(v.key)); raw != "" {
460			n, err := parseSize(raw)
461			if err != nil {
462				bad(v.key, err.Error())
463			} else {
464				*v.dst = n
465			}
466		}
467	}
468	if raw := get(EnvReleaseAssetsMax); raw != "" {
469		n, err := strconv.Atoi(raw)
470		if err != nil || n < 1 {
471			bad(EnvReleaseAssetsMax, "must be a positive integer")
472		} else {
473			c.ReleaseAssetsMax = n
474		}
475	}
476}
477
478// sizeUnits are the suffixes of parseSize, longest first.
479var sizeUnits = []struct {
480	suffix string
481	mult   int64
482}{{"TiB", 1 << 40}, {"GiB", 1 << 30}, {"MiB", 1 << 20}, {"KiB", 1 << 10}, {"B", 1}}
483
484// parseSize reads a size in bytes: a number with an optional suffix B, KiB,
485// MiB, GiB or TiB ("10GiB", "512 MiB"), from 1 MiB to 1 PiB.
486func parseSize(raw string) (int64, error) {
487	num, mult := raw, int64(1)
488	for _, u := range sizeUnits {
489		if n, ok := strings.CutSuffix(raw, u.suffix); ok {
490			num, mult = strings.TrimSpace(n), u.mult
491			break
492		}
493	}
494	n, err := strconv.ParseInt(num, 10, 64)
495	if err != nil || n < 1 || n > (1<<50)/mult || n*mult < 1<<20 {
496		return 0, fmt.Errorf("must be a size from 1MiB to 1024TiB such as 10GiB (got %q)", raw)
497	}
498	return n * mult, nil
499}
500
501// decodeSecretsKey reads 32 bytes in standard or URL-safe base64, with or
502// without padding.
503func decodeSecretsKey(raw string) ([]byte, bool) {
504	for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
505		if key, err := enc.DecodeString(raw); err == nil {
506			return key, len(key) == SecretsKeyLen
507		}
508	}
509	return nil, false
510}
511
512// parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
513// at least one hour.
514func parseRetention(raw string) (time.Duration, error) {
515	var d time.Duration
516	if days, ok := strings.CutSuffix(raw, "d"); ok {
517		n, err := strconv.Atoi(days)
518		if err != nil || n < 1 || n > 36500 {
519			return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw)
520		}
521		d = time.Duration(n) * 24 * time.Hour
522	} else {
523		var err error
524		if d, err = time.ParseDuration(raw); err != nil {
525			return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw)
526		}
527	}
528	if d < time.Hour {
529		return 0, fmt.Errorf("must be at least one hour (got %q)", raw)
530	}
531	return d, nil
532}
533
534// validateIdentity accepts a name or an email address for a Git identity: valid
535// UTF-8 without control characters or the angle brackets and line breaks that
536// Git does not allow in an identity.
537func validateIdentity(s string) error {
538	if !utf8.ValidString(s) {
539		return fmt.Errorf("must be valid UTF-8")
540	}
541	for _, r := range s {
542		if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
543			return fmt.Errorf("must not contain control characters, '<' or '>'")
544		}
545	}
546	return nil
547}
548
549func validateAddr(addr string) error {
550	_, port, err := net.SplitHostPort(addr)
551	if err != nil {
552		return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
553	}
554	n, err := strconv.Atoi(port)
555	if err != nil || n < 1 || n > 65535 {
556		return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
557	}
558	return nil
559}
560
561// parseURL accepts an http(s) URL with a host and no credentials, query or
562// fragment, and returns it without a trailing slash. A path is accepted only
563// with allowPath.
564func parseURL(raw string, allowPath bool) (string, error) {
565	u, err := url.Parse(raw)
566	if err != nil {
567		return "", fmt.Errorf("not a valid URL: %v", err)
568	}
569	switch {
570	case u.Scheme != "http" && u.Scheme != "https":
571		return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
572	case u.Host == "" || u.Hostname() == "":
573		return "", fmt.Errorf("host is missing")
574	case u.User != nil:
575		return "", fmt.Errorf("must not contain credentials")
576	case !allowPath && u.Path != "" && u.Path != "/":
577		return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
578	case u.RawQuery != "" || u.ForceQuery:
579		return "", fmt.Errorf("must not contain a query")
580	case u.Fragment != "" || strings.Contains(raw, "#"):
581		return "", fmt.Errorf("must not contain a fragment")
582	}
583	return strings.TrimSuffix(u.String(), "/"), nil
584}
585
586func parseCIDRs(raw string) ([]netip.Prefix, error) {
587	var out []netip.Prefix
588	for part := range strings.SplitSeq(raw, ",") {
589		part = strings.TrimSpace(part)
590		if part == "" {
591			return nil, fmt.Errorf("empty entry in the comma-separated list")
592		}
593		p, err := netip.ParsePrefix(part)
594		if err != nil {
595			return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
596		}
597		out = append(out, p.Masked())
598	}
599	return out, nil
600}

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 45

↑↓ moveenter openesc close