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