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