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