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 "path/filepath"
16 "strconv"
17 "strings"
18 "time"
19 "unicode/utf8"
20)
21
22// Variable names.
23const (
24 EnvAddr = "ERMITE_ADDR"
25 EnvDataDir = "ERMITE_DATA_DIR"
26 EnvPublicURL = "ERMITE_PUBLIC_URL"
27 EnvAdminToken = "ERMITE_ADMIN_TOKEN"
28 EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
29 EnvGit = "ERMITE_GIT"
30 EnvGitReader = "ERMITE_GIT_READER"
31 EnvSourceURL = "ERMITE_SOURCE_URL"
32 EnvLogLevel = "ERMITE_LOG_LEVEL"
33 EnvLogFormat = "ERMITE_LOG_FORMAT"
34 // EnvCommitterName and EnvCommitterEmail name the committer of the
35 // commits ermite creates (squash merges, ADR 0022).
36 EnvCommitterName = "ERMITE_COMMITTER_NAME"
37 EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
38 // EnvOwner is the owner login CI shows in the github context
39 // ("<owner>/<repo>"); it follows the rules of repository names.
40 EnvOwner = "ERMITE_OWNER"
41 // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
42 EnvCILogRetention = "ERMITE_CI_LOG_RETENTION"
43 // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
44 // when a reverse proxy cuts idle responses sooner than the default 50 s.
45 EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"
46 // EnvSecretsKey is the master key that encrypts CI secrets at rest: 32
47 // random bytes in base64, provided by the deployment secret store like
48 // ERMITE_ADMIN_TOKEN. Without it secrets cannot be set and jobs get none.
49 EnvSecretsKey = "ERMITE_SECRETS_KEY"
50 // EnvCIArtifacts turns the CI artifacts (artifacts v4) off when "0" or
51 // "false"; they are on by default.
52 EnvCIArtifacts = "ERMITE_CI_ARTIFACTS"
53 // EnvCIArtifactRetention is the default and longest retention of an
54 // artifact, EnvCIArtifactQuota the space of the artifacts of one
55 // repository, EnvCIArtifactMax the size of one artifact.
56 EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"
57 EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA"
58 EnvCIArtifactMax = "ERMITE_CI_ARTIFACT_MAX"
59)
60
61// SecretsKeyLen is the length of the secrets key in bytes (AES-256).
62const SecretsKeyLen = 32
63
64// Defaults.
65const (
66 DefaultAddr = ":7341"
67 DefaultGit = "git"
68 DefaultGitReader = GitReaderErmiteGit
69 DefaultLogLevel = "info"
70 DefaultLogFormat = "json"
71 // DefaultCommitterName is the committer name of the commits ermite creates.
72 DefaultCommitterName = "ermite"
73 // DefaultOwner is the owner login when ERMITE_OWNER is not set.
74 DefaultOwner = "owner"
75)
76
77// Values of ERMITE_GIT_READER.
78const (
79 // GitReaderCLI reads repositories with the git binary only.
80 GitReaderCLI = "cli"
81 // GitReaderErmiteGit reads them with the ermite-git library where it is
82 // proven faster, and with the git binary elsewhere.
83 GitReaderErmiteGit = "ermite-git"
84)
85
86// Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s
87// that the runner waits for one FetchJob; the lower one stops runners from
88// polling in a tight loop.
89const (
90 MinCIPollTimeout = time.Second
91 MaxCIPollTimeout = 55 * time.Second
92)
93
94// MinAdminTokenLen is the shortest accepted admin token, in characters.
95const MinAdminTokenLen = 32
96
97// Config is the validated server configuration.
98type Config struct {
99 // Addr is the listen address, "host:port".
100 Addr string
101 // DataDir is the absolute, clean data root.
102 DataDir string
103 // PublicURL is the base URL without a trailing slash.
104 PublicURL string
105 // AdminTokenHash is the SHA-256 of the admin token. The token itself is
106 // dropped; compare with a constant-time function.
107 AdminTokenHash [sha256.Size]byte
108 // TrustedProxies are the networks allowed to set X-Forwarded-For.
109 TrustedProxies []netip.Prefix
110 // Git is the git executable, a name or a path.
111 Git string
112 // GitReader is GitReaderCLI or GitReaderErmiteGit.
113 GitReader string
114 // SourceURL is the link to the source of the running version.
115 SourceURL string
116 // LogLevel is one of trace, debug, info, warn, error.
117 LogLevel string
118 // LogFormat is json or console.
119 LogFormat string
120 // CommitterName and CommitterEmail are recorded as the committer of the
121 // commits ermite creates. The email defaults to "ermite@<host of
122 // PublicURL>".
123 CommitterName, CommitterEmail string
124 // Owner is the owner login of the github context of CI runs.
125 Owner string
126 // CIRetention is how long CI data is kept; 0 (variable unset) leaves the
127 // default to the CI use case.
128 CIRetention time.Duration
129 // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
130 // leaves the default to the CI use case.
131 CIPollTimeout time.Duration
132 // SecretsKey is the decoded ERMITE_SECRETS_KEY (SecretsKeyLen bytes); nil
133 // when the variable is unset. It is a secret: never log it or put it in an
134 // error.
135 SecretsKey []byte
136 // CIArtifacts is false when ERMITE_CI_ARTIFACTS turns the artifacts off.
137 CIArtifacts bool
138 // CIArtifactRetention, CIArtifactQuota and CIArtifactMax are the limits of
139 // the artifacts; 0 (variable unset) leaves the default to the CI use case.
140 CIArtifactRetention time.Duration
141 CIArtifactQuota int64
142 CIArtifactMax int64
143}
144
145// Error lists every configuration problem found, in variable order.
146type Error struct {
147 Problems []string
148}
149
150// Error implements error.
151func (e *Error) Error() string {
152 var b strings.Builder
153 b.WriteString("invalid configuration:")
154 for _, p := range e.Problems {
155 b.WriteString("\n - ")
156 b.WriteString(p)
157 }
158 return b.String()
159}
160
161// Load reads the configuration through lookup, which returns the value of a
162// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
163// unset. All problems are reported together in an *Error. Messages name the
164// variable and never contain the admin token.
165func Load(get func(string) string) (Config, error) {
166 var problems []string
167 bad := func(key, format string, args ...any) {
168 problems = append(problems, key+": "+fmt.Sprintf(format, args...))
169 }
170 var c Config
171
172 c.Addr = get(EnvAddr)
173 if c.Addr == "" {
174 c.Addr = DefaultAddr
175 } else if err := validateAddr(c.Addr); err != nil {
176 bad(EnvAddr, "%v", err)
177 }
178
179 switch dir := get(EnvDataDir); {
180 case dir == "":
181 bad(EnvDataDir, "required")
182 case !filepath.IsAbs(dir):
183 bad(EnvDataDir, "must be an absolute path")
184 default:
185 c.DataDir = filepath.Clean(dir)
186 }
187
188 publicOK := false
189 if raw := get(EnvPublicURL); raw == "" {
190 bad(EnvPublicURL, "required")
191 } else if u, err := parseURL(raw, false); err != nil {
192 bad(EnvPublicURL, "%v", err)
193 } else {
194 c.PublicURL = u
195 publicOK = true
196 }
197
198 if token := get(EnvAdminToken); token == "" {
199 bad(EnvAdminToken, "required")
200 } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
201 bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
202 } else {
203 c.AdminTokenHash = sha256.Sum256([]byte(token))
204 }
205
206 if raw := get(EnvTrustedProxies); raw != "" {
207 prefixes, err := parseCIDRs(raw)
208 if err != nil {
209 bad(EnvTrustedProxies, "%v", err)
210 }
211 c.TrustedProxies = prefixes
212 }
213
214 c.Git = get(EnvGit)
215 if c.Git == "" {
216 c.Git = DefaultGit
217 }
218
219 c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
220 switch c.GitReader {
221 case "":
222 c.GitReader = DefaultGitReader
223 case GitReaderCLI, GitReaderErmiteGit:
224 default:
225 bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
226 }
227
228 switch raw := get(EnvSourceURL); {
229 case raw != "":
230 if u, err := parseURL(raw, true); err != nil {
231 bad(EnvSourceURL, "%v", err)
232 } else {
233 c.SourceURL = u
234 }
235 case publicOK:
236 c.SourceURL = c.PublicURL + "/ermite"
237 }
238
239 c.LogLevel = strings.ToLower(get(EnvLogLevel))
240 switch c.LogLevel {
241 case "":
242 c.LogLevel = DefaultLogLevel
243 case "trace", "debug", "info", "warn", "error":
244 default:
245 bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
246 }
247
248 c.LogFormat = strings.ToLower(get(EnvLogFormat))
249 switch c.LogFormat {
250 case "":
251 c.LogFormat = DefaultLogFormat
252 case "json", "console":
253 default:
254 bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
255 }
256
257 c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
258 if c.CommitterName == "" {
259 c.CommitterName = DefaultCommitterName
260 } else if err := validateIdentity(c.CommitterName); err != nil {
261 bad(EnvCommitterName, "%v", err)
262 }
263 c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
264 switch {
265 case c.CommitterEmail != "":
266 if err := validateIdentity(c.CommitterEmail); err != nil {
267 bad(EnvCommitterEmail, "%v", err)
268 } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
269 bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
270 }
271 case publicOK:
272 u, _ := url.Parse(c.PublicURL)
273 c.CommitterEmail = "ermite@" + u.Hostname()
274 }
275
276 // The name rules of the owner are those of repository names (the domain);
277 // config imports nothing, so internal/app checks them (see loadConfig).
278 c.Owner = strings.TrimSpace(get(EnvOwner))
279 if c.Owner == "" {
280 c.Owner = DefaultOwner
281 }
282
283 if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" {
284 d, err := parseRetention(raw)
285 if err != nil {
286 bad(EnvCILogRetention, "%v", err)
287 } else {
288 c.CIRetention = d
289 }
290 }
291
292 if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" {
293 d, err := time.ParseDuration(raw)
294 switch {
295 case err != nil:
296 bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw)
297 case d < MinCIPollTimeout || d > MaxCIPollTimeout:
298 bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw)
299 default:
300 c.CIPollTimeout = d
301 }
302 }
303
304 if raw := strings.TrimSpace(get(EnvSecretsKey)); raw != "" {
305 // The message never repeats the value.
306 if key, ok := decodeSecretsKey(raw); !ok {
307 bad(EnvSecretsKey, "must be %d random bytes in base64 (for example `head -c 32 /dev/urandom | base64`)", SecretsKeyLen)
308 } else {
309 c.SecretsKey = key
310 }
311 }
312
313 switch raw := strings.ToLower(strings.TrimSpace(get(EnvCIArtifacts))); raw {
314 case "", "1", "true":
315 c.CIArtifacts = true
316 case "0", "false":
317 default:
318 bad(EnvCIArtifacts, "must be 0, 1, false or true (got %q)", raw)
319 }
320 if raw := strings.TrimSpace(get(EnvCIArtifactRetention)); raw != "" {
321 d, err := parseRetention(raw)
322 switch {
323 case err != nil:
324 bad(EnvCIArtifactRetention, "%v", err)
325 case d < 24*time.Hour:
326 bad(EnvCIArtifactRetention, "must be at least one day (got %q)", raw)
327 default:
328 c.CIArtifactRetention = d
329 }
330 }
331 for _, v := range []struct {
332 env string
333 dst *int64
334 }{{EnvCIArtifactQuota, &c.CIArtifactQuota}, {EnvCIArtifactMax, &c.CIArtifactMax}} {
335 if raw := strings.TrimSpace(get(v.env)); raw != "" {
336 n, err := parseSize(raw)
337 if err != nil {
338 bad(v.env, "%v", err)
339 } else {
340 *v.dst = n
341 }
342 }
343 }
344
345 if len(problems) > 0 {
346 return Config{}, &Error{Problems: problems}
347 }
348 return c, nil
349}
350
351// sizeUnits are the suffixes of parseSize, longest first.
352var sizeUnits = []struct {
353 suffix string
354 mult int64
355}{{"TiB", 1 << 40}, {"GiB", 1 << 30}, {"MiB", 1 << 20}, {"KiB", 1 << 10}, {"B", 1}}
356
357// parseSize reads a size in bytes: a number with an optional suffix B, KiB,
358// MiB, GiB or TiB ("10GiB", "512 MiB"), from 1 MiB to 1 PiB.
359func parseSize(raw string) (int64, error) {
360 num, mult := raw, int64(1)
361 for _, u := range sizeUnits {
362 if n, ok := strings.CutSuffix(raw, u.suffix); ok {
363 num, mult = strings.TrimSpace(n), u.mult
364 break
365 }
366 }
367 n, err := strconv.ParseInt(num, 10, 64)
368 if err != nil || n < 1 || n > (1<<50)/mult || n*mult < 1<<20 {
369 return 0, fmt.Errorf("must be a size from 1MiB to 1024TiB such as 10GiB (got %q)", raw)
370 }
371 return n * mult, nil
372}
373
374// decodeSecretsKey reads 32 bytes in standard or URL-safe base64, with or
375// without padding.
376func decodeSecretsKey(raw string) ([]byte, bool) {
377 for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
378 if key, err := enc.DecodeString(raw); err == nil {
379 return key, len(key) == SecretsKeyLen
380 }
381 }
382 return nil, false
383}
384
385// parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
386// at least one hour.
387func parseRetention(raw string) (time.Duration, error) {
388 var d time.Duration
389 if days, ok := strings.CutSuffix(raw, "d"); ok {
390 n, err := strconv.Atoi(days)
391 if err != nil || n < 1 || n > 36500 {
392 return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw)
393 }
394 d = time.Duration(n) * 24 * time.Hour
395 } else {
396 var err error
397 if d, err = time.ParseDuration(raw); err != nil {
398 return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw)
399 }
400 }
401 if d < time.Hour {
402 return 0, fmt.Errorf("must be at least one hour (got %q)", raw)
403 }
404 return d, nil
405}
406
407// validateIdentity accepts a name or an email address for a Git identity: valid
408// UTF-8 without control characters or the angle brackets and line breaks that
409// Git does not allow in an identity.
410func validateIdentity(s string) error {
411 if !utf8.ValidString(s) {
412 return fmt.Errorf("must be valid UTF-8")
413 }
414 for _, r := range s {
415 if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
416 return fmt.Errorf("must not contain control characters, '<' or '>'")
417 }
418 }
419 return nil
420}
421
422func validateAddr(addr string) error {
423 _, port, err := net.SplitHostPort(addr)
424 if err != nil {
425 return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
426 }
427 n, err := strconv.Atoi(port)
428 if err != nil || n < 1 || n > 65535 {
429 return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
430 }
431 return nil
432}
433
434// parseURL accepts an http(s) URL with a host and no credentials, query or
435// fragment, and returns it without a trailing slash. A path is accepted only
436// with allowPath.
437func parseURL(raw string, allowPath bool) (string, error) {
438 u, err := url.Parse(raw)
439 if err != nil {
440 return "", fmt.Errorf("not a valid URL: %v", err)
441 }
442 switch {
443 case u.Scheme != "http" && u.Scheme != "https":
444 return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
445 case u.Host == "" || u.Hostname() == "":
446 return "", fmt.Errorf("host is missing")
447 case u.User != nil:
448 return "", fmt.Errorf("must not contain credentials")
449 case !allowPath && u.Path != "" && u.Path != "/":
450 return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
451 case u.RawQuery != "" || u.ForceQuery:
452 return "", fmt.Errorf("must not contain a query")
453 case u.Fragment != "" || strings.Contains(raw, "#"):
454 return "", fmt.Errorf("must not contain a fragment")
455 }
456 return strings.TrimSuffix(u.String(), "/"), nil
457}
458
459func parseCIDRs(raw string) ([]netip.Prefix, error) {
460 var out []netip.Prefix
461 for part := range strings.SplitSeq(raw, ",") {
462 part = strings.TrimSpace(part)
463 if part == "" {
464 return nil, fmt.Errorf("empty entry in the comma-separated list")
465 }
466 p, err := netip.ParsePrefix(part)
467 if err != nil {
468 return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
469 }
470 out = append(out, p.Masked())
471 }
472 return out, nil
473}