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