v0.6.0 d5cc8fe

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 an entrypoint helper: only cmd 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	"fmt"
11	"net"
12	"net/netip"
13	"net/url"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"unicode/utf8"
18)
19
20// Variable names.
21const (
22	EnvAddr           = "ERMITE_ADDR"
23	EnvDataDir        = "ERMITE_DATA_DIR"
24	EnvPublicURL      = "ERMITE_PUBLIC_URL"
25	EnvAdminToken     = "ERMITE_ADMIN_TOKEN"
26	EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
27	EnvGit            = "ERMITE_GIT"
28	EnvGitReader      = "ERMITE_GIT_READER"
29	EnvSourceURL      = "ERMITE_SOURCE_URL"
30	EnvLogLevel       = "ERMITE_LOG_LEVEL"
31	EnvLogFormat      = "ERMITE_LOG_FORMAT"
32	// EnvCommitterName and EnvCommitterEmail name the committer of the
33	// commits ermite creates (squash merges, ADR 0022).
34	EnvCommitterName  = "ERMITE_COMMITTER_NAME"
35	EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
36)
37
38// Defaults.
39const (
40	DefaultAddr      = ":7341"
41	DefaultGit       = "git"
42	DefaultGitReader = GitReaderErmiteGit
43	DefaultLogLevel  = "info"
44	DefaultLogFormat = "json"
45	// DefaultCommitterName is the committer name of the commits ermite creates.
46	DefaultCommitterName = "ermite"
47)
48
49// Values of ERMITE_GIT_READER.
50const (
51	// GitReaderCLI reads repositories with the git binary only.
52	GitReaderCLI = "cli"
53	// GitReaderErmiteGit reads them with the ermite-git library where it is
54	// proven faster, and with the git binary elsewhere.
55	GitReaderErmiteGit = "ermite-git"
56)
57
58// MinAdminTokenLen is the shortest accepted admin token, in characters.
59const MinAdminTokenLen = 32
60
61// Config is the validated server configuration.
62type Config struct {
63	// Addr is the listen address, "host:port".
64	Addr string
65	// DataDir is the absolute, clean data root.
66	DataDir string
67	// PublicURL is the base URL without a trailing slash.
68	PublicURL string
69	// AdminTokenHash is the SHA-256 of the admin token. The token itself is
70	// dropped; compare with a constant-time function.
71	AdminTokenHash [sha256.Size]byte
72	// TrustedProxies are the networks allowed to set X-Forwarded-For.
73	TrustedProxies []netip.Prefix
74	// Git is the git executable, a name or a path.
75	Git string
76	// GitReader is GitReaderCLI or GitReaderErmiteGit.
77	GitReader string
78	// SourceURL is the link to the source of the running version.
79	SourceURL string
80	// LogLevel is one of trace, debug, info, warn, error.
81	LogLevel string
82	// LogFormat is json or console.
83	LogFormat string
84	// CommitterName and CommitterEmail are recorded as the committer of the
85	// commits ermite creates. The email defaults to "ermite@<host of
86	// PublicURL>".
87	CommitterName, CommitterEmail string
88}
89
90// Error lists every configuration problem found, in variable order.
91type Error struct {
92	Problems []string
93}
94
95// Error implements error.
96func (e *Error) Error() string {
97	var b strings.Builder
98	b.WriteString("invalid configuration:")
99	for _, p := range e.Problems {
100		b.WriteString("\n  - ")
101		b.WriteString(p)
102	}
103	return b.String()
104}
105
106// Load reads the configuration through lookup, which returns the value of a
107// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
108// unset. All problems are reported together in an *Error. Messages name the
109// variable and never contain the admin token.
110func Load(get func(string) string) (Config, error) {
111	var problems []string
112	bad := func(key, format string, args ...any) {
113		problems = append(problems, key+": "+fmt.Sprintf(format, args...))
114	}
115	var c Config
116
117	c.Addr = get(EnvAddr)
118	if c.Addr == "" {
119		c.Addr = DefaultAddr
120	} else if err := validateAddr(c.Addr); err != nil {
121		bad(EnvAddr, "%v", err)
122	}
123
124	switch dir := get(EnvDataDir); {
125	case dir == "":
126		bad(EnvDataDir, "required")
127	case !filepath.IsAbs(dir):
128		bad(EnvDataDir, "must be an absolute path")
129	default:
130		c.DataDir = filepath.Clean(dir)
131	}
132
133	publicOK := false
134	if raw := get(EnvPublicURL); raw == "" {
135		bad(EnvPublicURL, "required")
136	} else if u, err := parseURL(raw, false); err != nil {
137		bad(EnvPublicURL, "%v", err)
138	} else {
139		c.PublicURL = u
140		publicOK = true
141	}
142
143	if token := get(EnvAdminToken); token == "" {
144		bad(EnvAdminToken, "required")
145	} else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
146		bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
147	} else {
148		c.AdminTokenHash = sha256.Sum256([]byte(token))
149	}
150
151	if raw := get(EnvTrustedProxies); raw != "" {
152		prefixes, err := parseCIDRs(raw)
153		if err != nil {
154			bad(EnvTrustedProxies, "%v", err)
155		}
156		c.TrustedProxies = prefixes
157	}
158
159	c.Git = get(EnvGit)
160	if c.Git == "" {
161		c.Git = DefaultGit
162	}
163
164	c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
165	switch c.GitReader {
166	case "":
167		c.GitReader = DefaultGitReader
168	case GitReaderCLI, GitReaderErmiteGit:
169	default:
170		bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
171	}
172
173	switch raw := get(EnvSourceURL); {
174	case raw != "":
175		if u, err := parseURL(raw, true); err != nil {
176			bad(EnvSourceURL, "%v", err)
177		} else {
178			c.SourceURL = u
179		}
180	case publicOK:
181		c.SourceURL = c.PublicURL + "/ermite"
182	}
183
184	c.LogLevel = strings.ToLower(get(EnvLogLevel))
185	switch c.LogLevel {
186	case "":
187		c.LogLevel = DefaultLogLevel
188	case "trace", "debug", "info", "warn", "error":
189	default:
190		bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
191	}
192
193	c.LogFormat = strings.ToLower(get(EnvLogFormat))
194	switch c.LogFormat {
195	case "":
196		c.LogFormat = DefaultLogFormat
197	case "json", "console":
198	default:
199		bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
200	}
201
202	c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
203	if c.CommitterName == "" {
204		c.CommitterName = DefaultCommitterName
205	} else if err := validateIdentity(c.CommitterName); err != nil {
206		bad(EnvCommitterName, "%v", err)
207	}
208	c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
209	switch {
210	case c.CommitterEmail != "":
211		if err := validateIdentity(c.CommitterEmail); err != nil {
212			bad(EnvCommitterEmail, "%v", err)
213		} else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
214			bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
215		}
216	case publicOK:
217		u, _ := url.Parse(c.PublicURL)
218		c.CommitterEmail = "ermite@" + u.Hostname()
219	}
220
221	if len(problems) > 0 {
222		return Config{}, &Error{Problems: problems}
223	}
224	return c, nil
225}
226
227// validateIdentity accepts a name or an email address for a Git identity: valid
228// UTF-8 without control characters or the angle brackets and line breaks that
229// Git does not allow in an identity.
230func validateIdentity(s string) error {
231	if !utf8.ValidString(s) {
232		return fmt.Errorf("must be valid UTF-8")
233	}
234	for _, r := range s {
235		if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
236			return fmt.Errorf("must not contain control characters, '<' or '>'")
237		}
238	}
239	return nil
240}
241
242func validateAddr(addr string) error {
243	_, port, err := net.SplitHostPort(addr)
244	if err != nil {
245		return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
246	}
247	n, err := strconv.Atoi(port)
248	if err != nil || n < 1 || n > 65535 {
249		return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
250	}
251	return nil
252}
253
254// parseURL accepts an http(s) URL with a host and no credentials, query or
255// fragment, and returns it without a trailing slash. A path is accepted only
256// with allowPath.
257func parseURL(raw string, allowPath bool) (string, error) {
258	u, err := url.Parse(raw)
259	if err != nil {
260		return "", fmt.Errorf("not a valid URL: %v", err)
261	}
262	switch {
263	case u.Scheme != "http" && u.Scheme != "https":
264		return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
265	case u.Host == "" || u.Hostname() == "":
266		return "", fmt.Errorf("host is missing")
267	case u.User != nil:
268		return "", fmt.Errorf("must not contain credentials")
269	case !allowPath && u.Path != "" && u.Path != "/":
270		return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
271	case u.RawQuery != "" || u.ForceQuery:
272		return "", fmt.Errorf("must not contain a query")
273	case u.Fragment != "" || strings.Contains(raw, "#"):
274		return "", fmt.Errorf("must not contain a fragment")
275	}
276	return strings.TrimSuffix(u.String(), "/"), nil
277}
278
279func parseCIDRs(raw string) ([]netip.Prefix, error) {
280	var out []netip.Prefix
281	for part := range strings.SplitSeq(raw, ",") {
282		part = strings.TrimSpace(part)
283		if part == "" {
284			return nil, fmt.Errorf("empty entry in the comma-separated list")
285		}
286		p, err := netip.ParsePrefix(part)
287		if err != nil {
288			return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
289		}
290		out = append(out, p.Masked())
291	}
292	return out, nil
293}

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 3

Tags 44

↑↓ moveenter openesc close