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