v0.1.0 3f267b5

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	EnvSourceURL      = "ERMITE_SOURCE_URL"
29	EnvLogLevel       = "ERMITE_LOG_LEVEL"
30	EnvLogFormat      = "ERMITE_LOG_FORMAT"
31)
32
33// Defaults.
34const (
35	DefaultAddr      = ":7341"
36	DefaultGit       = "git"
37	DefaultLogLevel  = "info"
38	DefaultLogFormat = "json"
39)
40
41// MinAdminTokenLen is the shortest accepted admin token, in characters.
42const MinAdminTokenLen = 32
43
44// Config is the validated server configuration.
45type Config struct {
46	// Addr is the listen address, "host:port".
47	Addr string
48	// DataDir is the absolute, clean data root.
49	DataDir string
50	// PublicURL is the base URL without a trailing slash.
51	PublicURL string
52	// AdminTokenHash is the SHA-256 of the admin token. The token itself is
53	// dropped; compare with a constant-time function.
54	AdminTokenHash [sha256.Size]byte
55	// TrustedProxies are the networks allowed to set X-Forwarded-For.
56	TrustedProxies []netip.Prefix
57	// Git is the git executable, a name or a path.
58	Git string
59	// SourceURL is the link to the source of the running version.
60	SourceURL string
61	// LogLevel is one of trace, debug, info, warn, error.
62	LogLevel string
63	// LogFormat is json or console.
64	LogFormat string
65}
66
67// Error lists every configuration problem found, in variable order.
68type Error struct {
69	Problems []string
70}
71
72// Error implements error.
73func (e *Error) Error() string {
74	var b strings.Builder
75	b.WriteString("invalid configuration:")
76	for _, p := range e.Problems {
77		b.WriteString("\n  - ")
78		b.WriteString(p)
79	}
80	return b.String()
81}
82
83// Load reads the configuration through lookup, which returns the value of a
84// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
85// unset. All problems are reported together in an *Error. Messages name the
86// variable and never contain the admin token.
87func Load(get func(string) string) (Config, error) {
88	var problems []string
89	bad := func(key, format string, args ...any) {
90		problems = append(problems, key+": "+fmt.Sprintf(format, args...))
91	}
92	var c Config
93
94	c.Addr = get(EnvAddr)
95	if c.Addr == "" {
96		c.Addr = DefaultAddr
97	} else if err := validateAddr(c.Addr); err != nil {
98		bad(EnvAddr, "%v", err)
99	}
100
101	switch dir := get(EnvDataDir); {
102	case dir == "":
103		bad(EnvDataDir, "required")
104	case !filepath.IsAbs(dir):
105		bad(EnvDataDir, "must be an absolute path")
106	default:
107		c.DataDir = filepath.Clean(dir)
108	}
109
110	publicOK := false
111	if raw := get(EnvPublicURL); raw == "" {
112		bad(EnvPublicURL, "required")
113	} else if u, err := parseURL(raw, false); err != nil {
114		bad(EnvPublicURL, "%v", err)
115	} else {
116		c.PublicURL = u
117		publicOK = true
118	}
119
120	if token := get(EnvAdminToken); token == "" {
121		bad(EnvAdminToken, "required")
122	} else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
123		bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
124	} else {
125		c.AdminTokenHash = sha256.Sum256([]byte(token))
126	}
127
128	if raw := get(EnvTrustedProxies); raw != "" {
129		prefixes, err := parseCIDRs(raw)
130		if err != nil {
131			bad(EnvTrustedProxies, "%v", err)
132		}
133		c.TrustedProxies = prefixes
134	}
135
136	c.Git = get(EnvGit)
137	if c.Git == "" {
138		c.Git = DefaultGit
139	}
140
141	switch raw := get(EnvSourceURL); {
142	case raw != "":
143		if u, err := parseURL(raw, true); err != nil {
144			bad(EnvSourceURL, "%v", err)
145		} else {
146			c.SourceURL = u
147		}
148	case publicOK:
149		c.SourceURL = c.PublicURL + "/ermite"
150	}
151
152	c.LogLevel = strings.ToLower(get(EnvLogLevel))
153	switch c.LogLevel {
154	case "":
155		c.LogLevel = DefaultLogLevel
156	case "trace", "debug", "info", "warn", "error":
157	default:
158		bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
159	}
160
161	c.LogFormat = strings.ToLower(get(EnvLogFormat))
162	switch c.LogFormat {
163	case "":
164		c.LogFormat = DefaultLogFormat
165	case "json", "console":
166	default:
167		bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
168	}
169
170	if len(problems) > 0 {
171		return Config{}, &Error{Problems: problems}
172	}
173	return c, nil
174}
175
176func validateAddr(addr string) error {
177	_, port, err := net.SplitHostPort(addr)
178	if err != nil {
179		return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
180	}
181	n, err := strconv.Atoi(port)
182	if err != nil || n < 1 || n > 65535 {
183		return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
184	}
185	return nil
186}
187
188// parseURL accepts an http(s) URL with a host and no credentials, query or
189// fragment, and returns it without a trailing slash. A path is accepted only
190// with allowPath.
191func parseURL(raw string, allowPath bool) (string, error) {
192	u, err := url.Parse(raw)
193	if err != nil {
194		return "", fmt.Errorf("not a valid URL: %v", err)
195	}
196	switch {
197	case u.Scheme != "http" && u.Scheme != "https":
198		return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
199	case u.Host == "" || u.Hostname() == "":
200		return "", fmt.Errorf("host is missing")
201	case u.User != nil:
202		return "", fmt.Errorf("must not contain credentials")
203	case !allowPath && u.Path != "" && u.Path != "/":
204		return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
205	case u.RawQuery != "" || u.ForceQuery:
206		return "", fmt.Errorf("must not contain a query")
207	case u.Fragment != "" || strings.Contains(raw, "#"):
208		return "", fmt.Errorf("must not contain a fragment")
209	}
210	return strings.TrimSuffix(u.String(), "/"), nil
211}
212
213func parseCIDRs(raw string) ([]netip.Prefix, error) {
214	var out []netip.Prefix
215	for part := range strings.SplitSeq(raw, ",") {
216		part = strings.TrimSpace(part)
217		if part == "" {
218			return nil, fmt.Errorf("empty entry in the comma-separated list")
219		}
220		p, err := netip.ParsePrefix(part)
221		if err != nil {
222			return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
223		}
224		out = append(out, p.Masked())
225	}
226	return out, nil
227}

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 43

↑↓ moveenter openesc close