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