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