feat(config): parse and validate the server environment
- commit
- aa54e60cf08e11c71c5cd2c63a53a920044da3a9
- author
- brice · · 2026-10-10 06:16 UTC
- parent
- 9db7dbf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +389 -0
A internal/config/config.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 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. | |
| 6 | + | package config | |
| 7 | + | ||
| 8 | + | import ( | |
| 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. | |
| 21 | + | const ( | |
| 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. | |
| 34 | + | const ( | |
| 35 | + | DefaultAddr = ":7341" | |
| 36 | + | DefaultGit = "git" | |
| 37 | + | DefaultLogLevel = "info" | |
| 38 | + | DefaultLogFormat = "json" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // MinAdminTokenLen is the shortest accepted admin token, in characters. | |
| 42 | + | const MinAdminTokenLen = 32 | |
| 43 | + | ||
| 44 | + | // Config is the validated server configuration. | |
| 45 | + | type 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. | |
| 68 | + | type Error struct { | |
| 69 | + | Problems []string | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Error implements error. | |
| 73 | + | func (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 and whether it is set (os.LookupEnv 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. | |
| 87 | + | func Load(lookup func(string) (string, bool)) (Config, error) { | |
| 88 | + | get := func(key string) string { | |
| 89 | + | v, _ := lookup(key) | |
| 90 | + | return v | |
| 91 | + | } | |
| 92 | + | var problems []string | |
| 93 | + | bad := func(key, format string, args ...any) { | |
| 94 | + | problems = append(problems, key+": "+fmt.Sprintf(format, args...)) | |
| 95 | + | } | |
| 96 | + | var c Config | |
| 97 | + | ||
| 98 | + | c.Addr = get(EnvAddr) | |
| 99 | + | if c.Addr == "" { | |
| 100 | + | c.Addr = DefaultAddr | |
| 101 | + | } else if err := validateAddr(c.Addr); err != nil { | |
| 102 | + | bad(EnvAddr, "%v", err) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | switch dir := get(EnvDataDir); { | |
| 106 | + | case dir == "": | |
| 107 | + | bad(EnvDataDir, "required") | |
| 108 | + | case !filepath.IsAbs(dir): | |
| 109 | + | bad(EnvDataDir, "must be an absolute path") | |
| 110 | + | default: | |
| 111 | + | c.DataDir = filepath.Clean(dir) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | publicOK := false | |
| 115 | + | if raw := get(EnvPublicURL); raw == "" { | |
| 116 | + | bad(EnvPublicURL, "required") | |
| 117 | + | } else if u, err := parseURL(raw, false); err != nil { | |
| 118 | + | bad(EnvPublicURL, "%v", err) | |
| 119 | + | } else { | |
| 120 | + | c.PublicURL = u | |
| 121 | + | publicOK = true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | if token := get(EnvAdminToken); token == "" { | |
| 125 | + | bad(EnvAdminToken, "required") | |
| 126 | + | } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen { | |
| 127 | + | bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n) | |
| 128 | + | } else { | |
| 129 | + | c.AdminTokenHash = sha256.Sum256([]byte(token)) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if raw := get(EnvTrustedProxies); raw != "" { | |
| 133 | + | prefixes, err := parseCIDRs(raw) | |
| 134 | + | if err != nil { | |
| 135 | + | bad(EnvTrustedProxies, "%v", err) | |
| 136 | + | } | |
| 137 | + | c.TrustedProxies = prefixes | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | c.Git = get(EnvGit) | |
| 141 | + | if c.Git == "" { | |
| 142 | + | c.Git = DefaultGit | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | switch raw := get(EnvSourceURL); { | |
| 146 | + | case raw != "": | |
| 147 | + | if u, err := parseURL(raw, true); err != nil { | |
| 148 | + | bad(EnvSourceURL, "%v", err) | |
| 149 | + | } else { | |
| 150 | + | c.SourceURL = u | |
| 151 | + | } | |
| 152 | + | case publicOK: | |
| 153 | + | c.SourceURL = c.PublicURL + "/ermite" | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | c.LogLevel = strings.ToLower(get(EnvLogLevel)) | |
| 157 | + | switch c.LogLevel { | |
| 158 | + | case "": | |
| 159 | + | c.LogLevel = DefaultLogLevel | |
| 160 | + | case "trace", "debug", "info", "warn", "error": | |
| 161 | + | default: | |
| 162 | + | bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel)) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | c.LogFormat = strings.ToLower(get(EnvLogFormat)) | |
| 166 | + | switch c.LogFormat { | |
| 167 | + | case "": | |
| 168 | + | c.LogFormat = DefaultLogFormat | |
| 169 | + | case "json", "console": | |
| 170 | + | default: | |
| 171 | + | bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat)) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | if len(problems) > 0 { | |
| 175 | + | return Config{}, &Error{Problems: problems} | |
| 176 | + | } | |
| 177 | + | return c, nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func validateAddr(addr string) error { | |
| 181 | + | _, port, err := net.SplitHostPort(addr) | |
| 182 | + | if err != nil { | |
| 183 | + | return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err) | |
| 184 | + | } | |
| 185 | + | n, err := strconv.Atoi(port) | |
| 186 | + | if err != nil || n < 1 || n > 65535 { | |
| 187 | + | return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port) | |
| 188 | + | } | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // parseURL accepts an http(s) URL with a host and no credentials, query or | |
| 193 | + | // fragment, and returns it without a trailing slash. A path is accepted only | |
| 194 | + | // with allowPath. | |
| 195 | + | func parseURL(raw string, allowPath bool) (string, error) { | |
| 196 | + | u, err := url.Parse(raw) | |
| 197 | + | if err != nil { | |
| 198 | + | return "", fmt.Errorf("not a valid URL: %v", err) | |
| 199 | + | } | |
| 200 | + | switch { | |
| 201 | + | case u.Scheme != "http" && u.Scheme != "https": | |
| 202 | + | return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme) | |
| 203 | + | case u.Host == "" || u.Hostname() == "": | |
| 204 | + | return "", fmt.Errorf("host is missing") | |
| 205 | + | case u.User != nil: | |
| 206 | + | return "", fmt.Errorf("must not contain credentials") | |
| 207 | + | case !allowPath && u.Path != "" && u.Path != "/": | |
| 208 | + | return "", fmt.Errorf("must not contain a path (got %q)", u.Path) | |
| 209 | + | case u.RawQuery != "" || u.ForceQuery: | |
| 210 | + | return "", fmt.Errorf("must not contain a query") | |
| 211 | + | case u.Fragment != "" || strings.Contains(raw, "#"): | |
| 212 | + | return "", fmt.Errorf("must not contain a fragment") | |
| 213 | + | } | |
| 214 | + | return strings.TrimSuffix(u.String(), "/"), nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func parseCIDRs(raw string) ([]netip.Prefix, error) { | |
| 218 | + | var out []netip.Prefix | |
| 219 | + | for part := range strings.SplitSeq(raw, ",") { | |
| 220 | + | part = strings.TrimSpace(part) | |
| 221 | + | if part == "" { | |
| 222 | + | return nil, fmt.Errorf("empty entry in the comma-separated list") | |
| 223 | + | } | |
| 224 | + | p, err := netip.ParsePrefix(part) | |
| 225 | + | if err != nil { | |
| 226 | + | return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part) | |
| 227 | + | } | |
| 228 | + | out = append(out, p.Masked()) | |
| 229 | + | } | |
| 230 | + | return out, nil | |
| 231 | + | } | |
| @@ -0,0 +1,231 @@ | |||||
| 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. | |||
| 6 | + | package config | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 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. | |||
| 21 | + | const ( | |||
| 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. | |||
| 34 | + | const ( | |||
| 35 | + | DefaultAddr = ":7341" | |||
| 36 | + | DefaultGit = "git" | |||
| 37 | + | DefaultLogLevel = "info" | |||
| 38 | + | DefaultLogFormat = "json" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // MinAdminTokenLen is the shortest accepted admin token, in characters. | |||
| 42 | + | const MinAdminTokenLen = 32 | |||
| 43 | + | ||||
| 44 | + | // Config is the validated server configuration. | |||
| 45 | + | type 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. | |||
| 68 | + | type Error struct { | |||
| 69 | + | Problems []string | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Error implements error. | |||
| 73 | + | func (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 and whether it is set (os.LookupEnv 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. | |||
| 87 | + | func Load(lookup func(string) (string, bool)) (Config, error) { | |||
| 88 | + | get := func(key string) string { | |||
| 89 | + | v, _ := lookup(key) | |||
| 90 | + | return v | |||
| 91 | + | } | |||
| 92 | + | var problems []string | |||
| 93 | + | bad := func(key, format string, args ...any) { | |||
| 94 | + | problems = append(problems, key+": "+fmt.Sprintf(format, args...)) | |||
| 95 | + | } | |||
| 96 | + | var c Config | |||
| 97 | + | ||||
| 98 | + | c.Addr = get(EnvAddr) | |||
| 99 | + | if c.Addr == "" { | |||
| 100 | + | c.Addr = DefaultAddr | |||
| 101 | + | } else if err := validateAddr(c.Addr); err != nil { | |||
| 102 | + | bad(EnvAddr, "%v", err) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | switch dir := get(EnvDataDir); { | |||
| 106 | + | case dir == "": | |||
| 107 | + | bad(EnvDataDir, "required") | |||
| 108 | + | case !filepath.IsAbs(dir): | |||
| 109 | + | bad(EnvDataDir, "must be an absolute path") | |||
| 110 | + | default: | |||
| 111 | + | c.DataDir = filepath.Clean(dir) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | publicOK := false | |||
| 115 | + | if raw := get(EnvPublicURL); raw == "" { | |||
| 116 | + | bad(EnvPublicURL, "required") | |||
| 117 | + | } else if u, err := parseURL(raw, false); err != nil { | |||
| 118 | + | bad(EnvPublicURL, "%v", err) | |||
| 119 | + | } else { | |||
| 120 | + | c.PublicURL = u | |||
| 121 | + | publicOK = true | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | if token := get(EnvAdminToken); token == "" { | |||
| 125 | + | bad(EnvAdminToken, "required") | |||
| 126 | + | } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen { | |||
| 127 | + | bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n) | |||
| 128 | + | } else { | |||
| 129 | + | c.AdminTokenHash = sha256.Sum256([]byte(token)) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | if raw := get(EnvTrustedProxies); raw != "" { | |||
| 133 | + | prefixes, err := parseCIDRs(raw) | |||
| 134 | + | if err != nil { | |||
| 135 | + | bad(EnvTrustedProxies, "%v", err) | |||
| 136 | + | } | |||
| 137 | + | c.TrustedProxies = prefixes | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | c.Git = get(EnvGit) | |||
| 141 | + | if c.Git == "" { | |||
| 142 | + | c.Git = DefaultGit | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | switch raw := get(EnvSourceURL); { | |||
| 146 | + | case raw != "": | |||
| 147 | + | if u, err := parseURL(raw, true); err != nil { | |||
| 148 | + | bad(EnvSourceURL, "%v", err) | |||
| 149 | + | } else { | |||
| 150 | + | c.SourceURL = u | |||
| 151 | + | } | |||
| 152 | + | case publicOK: | |||
| 153 | + | c.SourceURL = c.PublicURL + "/ermite" | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | c.LogLevel = strings.ToLower(get(EnvLogLevel)) | |||
| 157 | + | switch c.LogLevel { | |||
| 158 | + | case "": | |||
| 159 | + | c.LogLevel = DefaultLogLevel | |||
| 160 | + | case "trace", "debug", "info", "warn", "error": | |||
| 161 | + | default: | |||
| 162 | + | bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel)) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | c.LogFormat = strings.ToLower(get(EnvLogFormat)) | |||
| 166 | + | switch c.LogFormat { | |||
| 167 | + | case "": | |||
| 168 | + | c.LogFormat = DefaultLogFormat | |||
| 169 | + | case "json", "console": | |||
| 170 | + | default: | |||
| 171 | + | bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat)) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | if len(problems) > 0 { | |||
| 175 | + | return Config{}, &Error{Problems: problems} | |||
| 176 | + | } | |||
| 177 | + | return c, nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func validateAddr(addr string) error { | |||
| 181 | + | _, port, err := net.SplitHostPort(addr) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err) | |||
| 184 | + | } | |||
| 185 | + | n, err := strconv.Atoi(port) | |||
| 186 | + | if err != nil || n < 1 || n > 65535 { | |||
| 187 | + | return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port) | |||
| 188 | + | } | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // parseURL accepts an http(s) URL with a host and no credentials, query or | |||
| 193 | + | // fragment, and returns it without a trailing slash. A path is accepted only | |||
| 194 | + | // with allowPath. | |||
| 195 | + | func parseURL(raw string, allowPath bool) (string, error) { | |||
| 196 | + | u, err := url.Parse(raw) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return "", fmt.Errorf("not a valid URL: %v", err) | |||
| 199 | + | } | |||
| 200 | + | switch { | |||
| 201 | + | case u.Scheme != "http" && u.Scheme != "https": | |||
| 202 | + | return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme) | |||
| 203 | + | case u.Host == "" || u.Hostname() == "": | |||
| 204 | + | return "", fmt.Errorf("host is missing") | |||
| 205 | + | case u.User != nil: | |||
| 206 | + | return "", fmt.Errorf("must not contain credentials") | |||
| 207 | + | case !allowPath && u.Path != "" && u.Path != "/": | |||
| 208 | + | return "", fmt.Errorf("must not contain a path (got %q)", u.Path) | |||
| 209 | + | case u.RawQuery != "" || u.ForceQuery: | |||
| 210 | + | return "", fmt.Errorf("must not contain a query") | |||
| 211 | + | case u.Fragment != "" || strings.Contains(raw, "#"): | |||
| 212 | + | return "", fmt.Errorf("must not contain a fragment") | |||
| 213 | + | } | |||
| 214 | + | return strings.TrimSuffix(u.String(), "/"), nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func parseCIDRs(raw string) ([]netip.Prefix, error) { | |||
| 218 | + | var out []netip.Prefix | |||
| 219 | + | for part := range strings.SplitSeq(raw, ",") { | |||
| 220 | + | part = strings.TrimSpace(part) | |||
| 221 | + | if part == "" { | |||
| 222 | + | return nil, fmt.Errorf("empty entry in the comma-separated list") | |||
| 223 | + | } | |||
| 224 | + | p, err := netip.ParsePrefix(part) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part) | |||
| 227 | + | } | |||
| 228 | + | out = append(out, p.Masked()) | |||
| 229 | + | } | |||
| 230 | + | return out, nil | |||
| 231 | + | } | |||
A internal/config/config_test.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package config_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "errors" | |
| 8 | + | "net/netip" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/config" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const token = "0123456789abcdef0123456789abcdef" // 32 chars | |
| 19 | + | ||
| 20 | + | func lookup(env map[string]string) func(string) (string, bool) { | |
| 21 | + | return func(k string) (string, bool) { | |
| 22 | + | v, ok := env[k] | |
| 23 | + | return v, ok | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func valid(overrides map[string]string) map[string]string { | |
| 28 | + | env := map[string]string{ | |
| 29 | + | config.EnvDataDir: "/var/lib/ermite", | |
| 30 | + | config.EnvPublicURL: "https://git.example.com", | |
| 31 | + | config.EnvAdminToken: token, | |
| 32 | + | } | |
| 33 | + | for k, v := range overrides { | |
| 34 | + | if v == "\x00unset" { | |
| 35 | + | delete(env, k) | |
| 36 | + | continue | |
| 37 | + | } | |
| 38 | + | env[k] = v | |
| 39 | + | } | |
| 40 | + | return env | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestLoadDefaults(t *testing.T) { | |
| 44 | + | c, err := config.Load(lookup(valid(nil))) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | assert.Equal(t, ":7341", c.Addr) | |
| 47 | + | assert.Equal(t, "/var/lib/ermite", c.DataDir) | |
| 48 | + | assert.Equal(t, "https://git.example.com", c.PublicURL) | |
| 49 | + | assert.Equal(t, "git", c.Git) | |
| 50 | + | assert.Equal(t, "https://git.example.com/ermite", c.SourceURL) | |
| 51 | + | assert.Equal(t, "info", c.LogLevel) | |
| 52 | + | assert.Equal(t, "json", c.LogFormat) | |
| 53 | + | assert.Empty(t, c.TrustedProxies) | |
| 54 | + | assert.Equal(t, sha256.Sum256([]byte(token)), c.AdminTokenHash) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestLoadAllValues(t *testing.T) { | |
| 58 | + | c, err := config.Load(lookup(valid(map[string]string{ | |
| 59 | + | config.EnvAddr: "127.0.0.1:8080", | |
| 60 | + | config.EnvDataDir: "/srv/../srv/data/", | |
| 61 | + | config.EnvPublicURL: "http://localhost:7341/", | |
| 62 | + | config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8", | |
| 63 | + | config.EnvGit: "/usr/local/bin/git", | |
| 64 | + | config.EnvSourceURL: "https://forge.example.org/src/", | |
| 65 | + | config.EnvLogLevel: "DEBUG", | |
| 66 | + | config.EnvLogFormat: "Console", | |
| 67 | + | }))) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.Equal(t, "127.0.0.1:8080", c.Addr) | |
| 70 | + | assert.Equal(t, "/srv/data", c.DataDir) | |
| 71 | + | assert.Equal(t, "http://localhost:7341", c.PublicURL) | |
| 72 | + | assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies) | |
| 73 | + | assert.Equal(t, "/usr/local/bin/git", c.Git) | |
| 74 | + | assert.Equal(t, "https://forge.example.org/src", c.SourceURL) | |
| 75 | + | assert.Equal(t, "debug", c.LogLevel) | |
| 76 | + | assert.Equal(t, "console", c.LogFormat) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestLoadSourceURLFollowsPublicURL(t *testing.T) { | |
| 80 | + | c, err := config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://127.0.0.1:7341/"}))) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | assert.Equal(t, "http://127.0.0.1:7341/ermite", c.SourceURL) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestLoadProblems(t *testing.T) { | |
| 86 | + | tests := []struct { | |
| 87 | + | name string | |
| 88 | + | env map[string]string | |
| 89 | + | want string // variable named in the error | |
| 90 | + | msg string // fragment of its message | |
| 91 | + | }{ | |
| 92 | + | {"missing data dir", map[string]string{config.EnvDataDir: "\x00unset"}, config.EnvDataDir, "required"}, | |
| 93 | + | {"empty data dir", map[string]string{config.EnvDataDir: ""}, config.EnvDataDir, "required"}, | |
| 94 | + | {"relative data dir", map[string]string{config.EnvDataDir: "data"}, config.EnvDataDir, "absolute"}, | |
| 95 | + | {"missing public url", map[string]string{config.EnvPublicURL: "\x00unset"}, config.EnvPublicURL, "required"}, | |
| 96 | + | {"public url scheme", map[string]string{config.EnvPublicURL: "ftp://x.org"}, config.EnvPublicURL, "scheme"}, | |
| 97 | + | {"public url no scheme", map[string]string{config.EnvPublicURL: "git.example.com"}, config.EnvPublicURL, "scheme"}, | |
| 98 | + | {"public url no host", map[string]string{config.EnvPublicURL: "https://"}, config.EnvPublicURL, "host"}, | |
| 99 | + | {"public url path", map[string]string{config.EnvPublicURL: "https://x.org/forge"}, config.EnvPublicURL, "path"}, | |
| 100 | + | {"public url query", map[string]string{config.EnvPublicURL: "https://x.org?a=b"}, config.EnvPublicURL, "query"}, | |
| 101 | + | {"public url fragment", map[string]string{config.EnvPublicURL: "https://x.org#top"}, config.EnvPublicURL, "fragment"}, | |
| 102 | + | {"public url credentials", map[string]string{config.EnvPublicURL: "https://u:p@x.org"}, config.EnvPublicURL, "credentials"}, | |
| 103 | + | {"missing token", map[string]string{config.EnvAdminToken: "\x00unset"}, config.EnvAdminToken, "required"}, | |
| 104 | + | {"short token", map[string]string{config.EnvAdminToken: strings.Repeat("a", 31)}, config.EnvAdminToken, "at least 32"}, | |
| 105 | + | {"bad addr", map[string]string{config.EnvAddr: "7341"}, config.EnvAddr, "host:port"}, | |
| 106 | + | {"bad port", map[string]string{config.EnvAddr: ":http"}, config.EnvAddr, "port"}, | |
| 107 | + | {"port zero", map[string]string{config.EnvAddr: ":0"}, config.EnvAddr, "port"}, | |
| 108 | + | {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"}, | |
| 109 | + | {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"}, | |
| 110 | + | {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"}, | |
| 111 | + | {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"}, | |
| 112 | + | {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"}, | |
| 113 | + | {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"}, | |
| 114 | + | } | |
| 115 | + | for _, tt := range tests { | |
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |
| 117 | + | _, err := config.Load(lookup(valid(tt.env))) | |
| 118 | + | require.Error(t, err) | |
| 119 | + | var ce *config.Error | |
| 120 | + | require.True(t, errors.As(err, &ce)) | |
| 121 | + | require.Len(t, ce.Problems, 1, err.Error()) | |
| 122 | + | assert.True(t, strings.HasPrefix(ce.Problems[0], tt.want+":"), ce.Problems[0]) | |
| 123 | + | assert.Contains(t, ce.Problems[0], tt.msg) | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestLoadReportsEveryProblemTogether(t *testing.T) { | |
| 129 | + | _, err := config.Load(lookup(map[string]string{ | |
| 130 | + | config.EnvAddr: "bad", | |
| 131 | + | config.EnvLogLevel: "loud", | |
| 132 | + | config.EnvLogFormat: "xml", | |
| 133 | + | })) | |
| 134 | + | require.Error(t, err) | |
| 135 | + | msg := err.Error() | |
| 136 | + | for _, name := range []string{ | |
| 137 | + | config.EnvAddr, config.EnvDataDir, config.EnvPublicURL, config.EnvAdminToken, config.EnvLogLevel, config.EnvLogFormat, | |
| 138 | + | } { | |
| 139 | + | assert.Contains(t, msg, name) | |
| 140 | + | } | |
| 141 | + | var ce *config.Error | |
| 142 | + | require.True(t, errors.As(err, &ce)) | |
| 143 | + | assert.Len(t, ce.Problems, 6) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestErrorNeverContainsToken(t *testing.T) { | |
| 147 | + | short := "secret-but-too-short" | |
| 148 | + | _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: short, config.EnvAddr: "bad"}))) | |
| 149 | + | require.Error(t, err) | |
| 150 | + | assert.NotContains(t, err.Error(), short) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestTokenLengthCountsCharacters(t *testing.T) { | |
| 154 | + | _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 32)}))) | |
| 155 | + | assert.NoError(t, err) | |
| 156 | + | _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)}))) | |
| 157 | + | assert.Error(t, err) | |
| 158 | + | } | |
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package config_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/netip" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/config" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const token = "0123456789abcdef0123456789abcdef" // 32 chars | |||
| 19 | + | ||||
| 20 | + | func lookup(env map[string]string) func(string) (string, bool) { | |||
| 21 | + | return func(k string) (string, bool) { | |||
| 22 | + | v, ok := env[k] | |||
| 23 | + | return v, ok | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func valid(overrides map[string]string) map[string]string { | |||
| 28 | + | env := map[string]string{ | |||
| 29 | + | config.EnvDataDir: "/var/lib/ermite", | |||
| 30 | + | config.EnvPublicURL: "https://git.example.com", | |||
| 31 | + | config.EnvAdminToken: token, | |||
| 32 | + | } | |||
| 33 | + | for k, v := range overrides { | |||
| 34 | + | if v == "\x00unset" { | |||
| 35 | + | delete(env, k) | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | env[k] = v | |||
| 39 | + | } | |||
| 40 | + | return env | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestLoadDefaults(t *testing.T) { | |||
| 44 | + | c, err := config.Load(lookup(valid(nil))) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | assert.Equal(t, ":7341", c.Addr) | |||
| 47 | + | assert.Equal(t, "/var/lib/ermite", c.DataDir) | |||
| 48 | + | assert.Equal(t, "https://git.example.com", c.PublicURL) | |||
| 49 | + | assert.Equal(t, "git", c.Git) | |||
| 50 | + | assert.Equal(t, "https://git.example.com/ermite", c.SourceURL) | |||
| 51 | + | assert.Equal(t, "info", c.LogLevel) | |||
| 52 | + | assert.Equal(t, "json", c.LogFormat) | |||
| 53 | + | assert.Empty(t, c.TrustedProxies) | |||
| 54 | + | assert.Equal(t, sha256.Sum256([]byte(token)), c.AdminTokenHash) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestLoadAllValues(t *testing.T) { | |||
| 58 | + | c, err := config.Load(lookup(valid(map[string]string{ | |||
| 59 | + | config.EnvAddr: "127.0.0.1:8080", | |||
| 60 | + | config.EnvDataDir: "/srv/../srv/data/", | |||
| 61 | + | config.EnvPublicURL: "http://localhost:7341/", | |||
| 62 | + | config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8", | |||
| 63 | + | config.EnvGit: "/usr/local/bin/git", | |||
| 64 | + | config.EnvSourceURL: "https://forge.example.org/src/", | |||
| 65 | + | config.EnvLogLevel: "DEBUG", | |||
| 66 | + | config.EnvLogFormat: "Console", | |||
| 67 | + | }))) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | assert.Equal(t, "127.0.0.1:8080", c.Addr) | |||
| 70 | + | assert.Equal(t, "/srv/data", c.DataDir) | |||
| 71 | + | assert.Equal(t, "http://localhost:7341", c.PublicURL) | |||
| 72 | + | assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies) | |||
| 73 | + | assert.Equal(t, "/usr/local/bin/git", c.Git) | |||
| 74 | + | assert.Equal(t, "https://forge.example.org/src", c.SourceURL) | |||
| 75 | + | assert.Equal(t, "debug", c.LogLevel) | |||
| 76 | + | assert.Equal(t, "console", c.LogFormat) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestLoadSourceURLFollowsPublicURL(t *testing.T) { | |||
| 80 | + | c, err := config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://127.0.0.1:7341/"}))) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | assert.Equal(t, "http://127.0.0.1:7341/ermite", c.SourceURL) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestLoadProblems(t *testing.T) { | |||
| 86 | + | tests := []struct { | |||
| 87 | + | name string | |||
| 88 | + | env map[string]string | |||
| 89 | + | want string // variable named in the error | |||
| 90 | + | msg string // fragment of its message | |||
| 91 | + | }{ | |||
| 92 | + | {"missing data dir", map[string]string{config.EnvDataDir: "\x00unset"}, config.EnvDataDir, "required"}, | |||
| 93 | + | {"empty data dir", map[string]string{config.EnvDataDir: ""}, config.EnvDataDir, "required"}, | |||
| 94 | + | {"relative data dir", map[string]string{config.EnvDataDir: "data"}, config.EnvDataDir, "absolute"}, | |||
| 95 | + | {"missing public url", map[string]string{config.EnvPublicURL: "\x00unset"}, config.EnvPublicURL, "required"}, | |||
| 96 | + | {"public url scheme", map[string]string{config.EnvPublicURL: "ftp://x.org"}, config.EnvPublicURL, "scheme"}, | |||
| 97 | + | {"public url no scheme", map[string]string{config.EnvPublicURL: "git.example.com"}, config.EnvPublicURL, "scheme"}, | |||
| 98 | + | {"public url no host", map[string]string{config.EnvPublicURL: "https://"}, config.EnvPublicURL, "host"}, | |||
| 99 | + | {"public url path", map[string]string{config.EnvPublicURL: "https://x.org/forge"}, config.EnvPublicURL, "path"}, | |||
| 100 | + | {"public url query", map[string]string{config.EnvPublicURL: "https://x.org?a=b"}, config.EnvPublicURL, "query"}, | |||
| 101 | + | {"public url fragment", map[string]string{config.EnvPublicURL: "https://x.org#top"}, config.EnvPublicURL, "fragment"}, | |||
| 102 | + | {"public url credentials", map[string]string{config.EnvPublicURL: "https://u:p@x.org"}, config.EnvPublicURL, "credentials"}, | |||
| 103 | + | {"missing token", map[string]string{config.EnvAdminToken: "\x00unset"}, config.EnvAdminToken, "required"}, | |||
| 104 | + | {"short token", map[string]string{config.EnvAdminToken: strings.Repeat("a", 31)}, config.EnvAdminToken, "at least 32"}, | |||
| 105 | + | {"bad addr", map[string]string{config.EnvAddr: "7341"}, config.EnvAddr, "host:port"}, | |||
| 106 | + | {"bad port", map[string]string{config.EnvAddr: ":http"}, config.EnvAddr, "port"}, | |||
| 107 | + | {"port zero", map[string]string{config.EnvAddr: ":0"}, config.EnvAddr, "port"}, | |||
| 108 | + | {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"}, | |||
| 109 | + | {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"}, | |||
| 110 | + | {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"}, | |||
| 111 | + | {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"}, | |||
| 112 | + | {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"}, | |||
| 113 | + | {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"}, | |||
| 114 | + | } | |||
| 115 | + | for _, tt := range tests { | |||
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 117 | + | _, err := config.Load(lookup(valid(tt.env))) | |||
| 118 | + | require.Error(t, err) | |||
| 119 | + | var ce *config.Error | |||
| 120 | + | require.True(t, errors.As(err, &ce)) | |||
| 121 | + | require.Len(t, ce.Problems, 1, err.Error()) | |||
| 122 | + | assert.True(t, strings.HasPrefix(ce.Problems[0], tt.want+":"), ce.Problems[0]) | |||
| 123 | + | assert.Contains(t, ce.Problems[0], tt.msg) | |||
| 124 | + | }) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestLoadReportsEveryProblemTogether(t *testing.T) { | |||
| 129 | + | _, err := config.Load(lookup(map[string]string{ | |||
| 130 | + | config.EnvAddr: "bad", | |||
| 131 | + | config.EnvLogLevel: "loud", | |||
| 132 | + | config.EnvLogFormat: "xml", | |||
| 133 | + | })) | |||
| 134 | + | require.Error(t, err) | |||
| 135 | + | msg := err.Error() | |||
| 136 | + | for _, name := range []string{ | |||
| 137 | + | config.EnvAddr, config.EnvDataDir, config.EnvPublicURL, config.EnvAdminToken, config.EnvLogLevel, config.EnvLogFormat, | |||
| 138 | + | } { | |||
| 139 | + | assert.Contains(t, msg, name) | |||
| 140 | + | } | |||
| 141 | + | var ce *config.Error | |||
| 142 | + | require.True(t, errors.As(err, &ce)) | |||
| 143 | + | assert.Len(t, ce.Problems, 6) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestErrorNeverContainsToken(t *testing.T) { | |||
| 147 | + | short := "secret-but-too-short" | |||
| 148 | + | _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: short, config.EnvAddr: "bad"}))) | |||
| 149 | + | require.Error(t, err) | |||
| 150 | + | assert.NotContains(t, err.Error(), short) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestTokenLengthCountsCharacters(t *testing.T) { | |||
| 154 | + | _, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 32)}))) | |||
| 155 | + | assert.NoError(t, err) | |||
| 156 | + | _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)}))) | |||
| 157 | + | assert.Error(t, err) | |||
| 158 | + | } | |||