vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
ed181212
Keenan Nemetz
13d ago
|
1 | package config |
| 2 | ||
| 3 | // Validation of the configuration the server is about to run with. | |
| 4 | // | |
| 5 | // The settings in this file are not independent: a development login behind a | |
| 6 | // public URL, an HTTPS listener with no name to certify, a self-registration | |
| 7 | // form on an origin WebAuthn will not accept. Each of those used to be a warning | |
| 8 | // at best, and a warning an operator never reads is not a control. Validate is | |
| 9 | // the reason `vole serve` refuses to start instead. | |
| 10 | // | |
| 11 | // It is deliberately strict about shape and permissive about values: a hostname | |
| 12 | // may be anything DNS allows, but it may not be a URL, and a size may be any | |
| 13 | // non-negative number, but not a negative one. | |
| 14 | ||
| 15 | import ( | |
| 16 | "errors" | |
| 17 | "fmt" | |
| 18 | "net" | |
| 19 | "net/url" | |
| 20 | "regexp" | |
| 21 | "strconv" | |
| 22 | "strings" | |
| 23 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
24 | "rodent.software/vole/forge/access" |
|
ed181212
Keenan Nemetz
13d ago
|
25 | ) |
| 26 | ||
| 27 | // accountNameRE is the roster's account-name rule. It is duplicated here rather | |
| 28 | // than imported because config is the leaf package the rest of the instance | |
| 29 | // builds on; TestAccountNameRuleMatchesRoster keeps the two in step. | |
| 30 | var accountNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | |
| 31 | ||
| 32 | // dnsNameRE is a host name without a trailing dot: one or more labels, each | |
| 33 | // starting and ending alphanumeric. | |
| 34 | var dnsNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) | |
| 35 | ||
| 36 | // emailRE is not RFC 5322. It is a check that the value is a single address and | |
| 37 | // not a list, a display name, or a line of something else that reached the file. | |
| 38 | var emailRE = regexp.MustCompile(`^[^@\s,;<>"]+@[^@\s,;<>"]+\.[^@\s,;<>"]+$`) | |
| 39 | ||
| 40 | // Validate reports every reason this configuration must not start. It returns an | |
| 41 | // error that names all of them at once, because an operator fixing a config file | |
| 42 | // should not have to restart the service to find the next problem. | |
| 43 | func (c *Config) Validate() error { | |
| 44 | var problems []string | |
| 45 | add := func(format string, args ...any) { | |
| 46 | problems = append(problems, fmt.Sprintf(format, args...)) | |
| 47 | } | |
| 48 | ||
| 49 | // Listeners. | |
| 50 | httpHost, _, httpErr := splitAddr("http_addr", c.HTTPAddr) | |
| 51 | if httpErr != nil { | |
| 52 | add("%v", httpErr) | |
| 53 | } | |
| 54 | if _, _, err := splitAddr("ssh_addr", c.SSHAddr); err != nil { | |
| 55 | add("%v", err) | |
| 56 | } | |
| 57 | if strings.TrimSpace(c.HTTPSAddr) != "" { | |
| 58 | if _, _, err := splitAddr("https_addr", c.HTTPSAddr); err != nil { | |
| 59 | add("%v", err) | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | // The public URL is an origin, not a link: WebAuthn binds credentials to it, | |
| 64 | // and every generated link is built by appending a path to it. | |
| 65 | public, err := ParsePublicURL(c.PublicURL) | |
| 66 | if err != nil { | |
| 67 | add("%v", err) | |
| 68 | } | |
| 69 | if c.PagesDomain != "" && !validDNSSuffix(c.PagesDomain) { | |
| 70 | add("pages_domain must be a bare DNS suffix such as %q (lowercase labels, no scheme, port or path), got %q", | |
| 71 | "example.com", c.PagesDomain) | |
| 72 | } | |
| 73 | if c.PagesRef != "" && !validRefName(c.PagesRef) { | |
| 74 | add("pages_ref must be a branch name, got %q", c.PagesRef) | |
| 75 | } | |
| 76 | ||
| 77 | // Development login is the one switch that hands out a session without a | |
| 78 | // credential. Three things have to agree before it is allowed: the operator | |
| 79 | // said the instance is a development instance, nothing public is configured, | |
| 80 | // and the listener could not be reached from anywhere but the machine. | |
| 81 | if name := strings.TrimSpace(c.DevLogin); name != "" { | |
| 82 | switch { | |
| 83 | case !c.DevOnly: | |
| 84 | add("dev_login requires dev_only = true: development login signs a browser in without a credential and must never be reachable from a public instance") | |
| 85 | case !accountNameRE.MatchString(name): | |
| 86 | add("dev_login must name an account (lowercase letters, digits, dash, underscore), got %q", name) | |
| 87 | case strings.TrimSpace(c.HTTPSAddr) != "": | |
| 88 | add("dev_login cannot be combined with https_addr: that listener is reachable from the network") | |
| 89 | case public != nil && !loopbackHost(public.Hostname()): | |
| 90 | add("dev_login cannot be combined with a public_url that is not loopback (%q): a published instance is not a development instance", c.PublicURL) | |
| 91 | case httpErr == nil && !loopbackHost(httpHost): | |
| 92 | add("dev_login requires http_addr on a loopback address, got %q", c.HTTPAddr) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | // Browser registration is only meaningful on an origin a browser will trust | |
| 97 | // with a passkey, and WebAuthn requires HTTPS for everything but localhost. | |
| 98 | if c.OpenRegistration { | |
| 99 | switch { | |
| 100 | case public == nil: | |
| 101 | add("open_registration requires public_url: registration is a browser flow on the page's origin") | |
| 102 | case public.Scheme != "https": | |
| 103 | add("open_registration requires an https public_url, got %q", c.PublicURL) | |
| 104 | } | |
| 105 | } | |
| 106 | if role := strings.TrimSpace(c.OpenRegistrationRole); role != "" { | |
| 107 | r, err := access.ParseRole(role) | |
|
9e8cc155
Keenan Nemetz
12d ago
|
108 | switch { |
| 109 | case err != nil: | |
|
ed181212
Keenan Nemetz
13d ago
|
110 | add("open_registration_role: %v", err) |
|
9e8cc155
Keenan Nemetz
12d ago
|
111 | case r.AtLeast(access.Maintain): |
| 112 | // Anyone may sign up on an instance that opened registration; moving | |
| 113 | // shared branches and approving reviews is not something a stranger | |
| 114 | // should be able to do by typing a name. | |
| 115 | add("open_registration_role must be none, read or contribute, got %q: self-registration cannot grant review or branch rights", role) | |
|
ed181212
Keenan Nemetz
13d ago
|
116 | } |
| 117 | } | |
| 118 | ||
| 119 | // Sizes and limits. Zero means "no bound" for the push limits and "default" | |
| 120 | // for the rest, which the doc comments already explain; negative is always a | |
| 121 | // mistake. | |
| 122 | for _, s := range []struct { | |
| 123 | name string | |
| 124 | v int64 | |
| 125 | }{ | |
| 126 | {"max_blob_bytes", c.MaxBlobBytes}, | |
| 127 | {"receive_max_bytes", c.ReceiveMaxBytes}, | |
| 128 | {"lfs_object_max_bytes", c.LFSObjectMaxBytes}, | |
| 129 | {"lfs_warn_bytes", c.LFSWarnBytes}, | |
| 130 | {"pages_cache_bytes", c.PagesCacheBytes}, | |
|
9e8cc155
Keenan Nemetz
12d ago
|
131 | {"repo_max_metadata_bytes", c.RepoMaxMetadataBytes}, |
| 132 | {"repo_max_bytes", c.RepoMaxBytes}, | |
| 133 | {"repo_max_lfs_bytes", c.RepoMaxLFSBytes}, | |
|
ed181212
Keenan Nemetz
13d ago
|
134 | } { |
| 135 | if s.v < 0 { | |
| 136 | add("%s must not be negative, got %d", s.name, s.v) | |
| 137 | } | |
| 138 | } | |
| 139 | if c.MaxBlobBytes > 0 && c.ReceiveMaxBytes > 0 && c.ReceiveMaxBytes < c.MaxBlobBytes { | |
| 140 | add("receive_max_bytes (%d) is smaller than max_blob_bytes (%d), so no push containing a permitted file could be accepted", | |
| 141 | c.ReceiveMaxBytes, c.MaxBlobBytes) | |
| 142 | } | |
| 143 | if c.LeaseSeconds < 0 || c.LeaseSeconds > 3600 { | |
| 144 | add("lease_seconds must be between 0 and 3600, got %d: a longer lease hands a dead runner's job to nobody", c.LeaseSeconds) | |
| 145 | } | |
| 146 | if c.MaxJobsPerRepo < 0 { | |
| 147 | add("max_jobs_per_repo must not be negative, got %d", c.MaxJobsPerRepo) | |
| 148 | } | |
|
9e8cc155
Keenan Nemetz
12d ago
|
149 | if c.RepoMaxRefs < 0 { |
| 150 | add("repo_max_refs must not be negative, got %d", c.RepoMaxRefs) | |
| 151 | } | |
| 152 | if c.RepoMaxMetadataEntries < 0 { | |
| 153 | add("repo_max_metadata_entries must not be negative, got %d", c.RepoMaxMetadataEntries) | |
| 154 | } | |
|
e5542ac6
Keenan Nemetz
12d ago
|
155 | if c.ContributorWritesPerHour < 0 { |
| 156 | add("contributor_writes_per_hour must not be negative, got %d", c.ContributorWritesPerHour) | |
|
9e8cc155
Keenan Nemetz
12d ago
|
157 | } |
|
ed181212
Keenan Nemetz
13d ago
|
158 | if c.PagesProcs < 0 || c.PagesProcs > 64 { |
| 159 | add("pages_procs must be between 0 and 64, got %d", c.PagesProcs) | |
| 160 | } | |
|
78cd3437
Keenan Nemetz
12d ago
|
161 | if c.SSHMaxConnections < 0 { |
| 162 | add("ssh_max_connections must not be negative, got %d", c.SSHMaxConnections) | |
| 163 | } | |
| 164 | if c.SSHMaxGitProcesses < 0 { | |
| 165 | add("ssh_max_git_processes must not be negative, got %d", c.SSHMaxGitProcesses) | |
| 166 | } | |
| 167 | if c.SSHHandshakeTimeoutSeconds < 0 || c.SSHHandshakeTimeoutSeconds > 300 { | |
| 168 | add("ssh_handshake_timeout_seconds must be between 0 and 300, got %d", c.SSHHandshakeTimeoutSeconds) | |
| 169 | } | |
|
ed181212
Keenan Nemetz
13d ago
|
170 | if c.GitHTTPMaxConcurrent < 0 { |
| 171 | add("git_http_max_concurrent must not be negative, got %d", c.GitHTTPMaxConcurrent) | |
| 172 | } | |
| 173 | if c.GitHTTPMaxPerClient < 0 { | |
| 174 | add("git_http_max_per_client must not be negative, got %d", c.GitHTTPMaxPerClient) | |
| 175 | } | |
| 176 | if c.GitHTTPTimeoutSeconds < 0 { | |
| 177 | add("git_http_timeout_seconds must not be negative, got %d", c.GitHTTPTimeoutSeconds) | |
| 178 | } | |
| 179 | ||
| 180 | // ACME. The certificate authority gets one address to complain to and one | |
| 181 | // directory to fetch, and a mistyped directory URL is a failed renewal weeks | |
| 182 | // after the typo. | |
| 183 | if e := strings.TrimSpace(c.ACMEEmail); e != "" && !emailRE.MatchString(e) { | |
| 184 | add("acme_email must be a single email address, got %q", e) | |
| 185 | } | |
| 186 | for _, d := range c.ACMEDomains { | |
| 187 | if !validDNSSuffix(d) || !strings.Contains(d, ".") { | |
| 188 | add("acme_domains: %q is not a fully qualified host name", d) | |
| 189 | } | |
| 190 | } | |
| 191 | if raw := strings.TrimSpace(c.ACMEDirectoryURL); raw != "" { | |
| 192 | u, err := url.Parse(raw) | |
| 193 | switch { | |
| 194 | case err != nil: | |
| 195 | add("acme_directory_url: %v", err) | |
| 196 | case u.Scheme != "https": | |
| 197 | add("acme_directory_url must be https, got %q", raw) | |
| 198 | case u.Host == "": | |
| 199 | add("acme_directory_url must name a host, got %q", raw) | |
| 200 | case u.RawQuery != "" || u.Fragment != "": | |
| 201 | add("acme_directory_url must not carry a query or fragment, got %q", raw) | |
| 202 | } | |
| 203 | } | |
| 204 | if strings.TrimSpace(c.HTTPSAddr) != "" && len(c.ACMEDomains) == 0 && | |
| 205 | (public == nil || public.Hostname() == "") { | |
| 206 | add("https_addr needs acme_domains or an https public_url: without a name there is no certificate to obtain") | |
| 207 | } | |
| 208 | ||
| 209 | if len(problems) == 0 { | |
| 210 | return nil | |
| 211 | } | |
| 212 | return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - ")) | |
| 213 | } | |
| 214 | ||
| 215 | // ParsePublicURL validates an instance's canonical browser origin. An empty value | |
| 216 | // is valid: an instance may have no browser entry point. A trailing slash is | |
| 217 | // ignored by BaseURL, so it is accepted here. | |
| 218 | func ParsePublicURL(raw string) (*url.URL, error) { | |
| 219 | raw = strings.TrimSpace(raw) | |
| 220 | if raw == "" { | |
| 221 | return nil, nil | |
| 222 | } | |
| 223 | u, err := url.Parse(raw) | |
| 224 | if err != nil { | |
| 225 | return nil, fmt.Errorf("public_url: %v", err) | |
| 226 | } | |
| 227 | switch { | |
| 228 | case u.Scheme != "https" && u.Scheme != "http": | |
| 229 | return nil, fmt.Errorf("public_url must start with https:// or http://, got %q", raw) | |
| 230 | case u.User != nil: | |
| 231 | return nil, fmt.Errorf("public_url must not carry a user or password: %q", raw) | |
| 232 | case u.RawQuery != "" || u.Fragment != "": | |
| 233 | return nil, fmt.Errorf("public_url must not carry a query or fragment: %q", raw) | |
| 234 | case u.Path != "" && u.Path != "/": | |
| 235 | return nil, fmt.Errorf("public_url must be an origin with no path, got %q", raw) | |
| 236 | case u.Hostname() == "": | |
| 237 | return nil, fmt.Errorf("public_url must name a host: %q", raw) | |
| 238 | } | |
| 239 | if p := u.Port(); p != "" { | |
| 240 | if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { | |
| 241 | return nil, fmt.Errorf("public_url has an invalid port: %q", p) | |
| 242 | } | |
| 243 | } | |
| 244 | if !validDNSSuffix(u.Hostname()) && net.ParseIP(u.Hostname()) == nil { | |
| 245 | return nil, fmt.Errorf("public_url host %q is not a host name or address", u.Hostname()) | |
| 246 | } | |
| 247 | return u, nil | |
| 248 | } | |
| 249 | ||
| 250 | // splitAddr parses host:port as the listeners need it. An empty host is valid and | |
| 251 | // means every interface; the port must be a number, because a listener whose port | |
| 252 | // is a name is a listener that can be steered by /etc/services. | |
| 253 | func splitAddr(name, addr string) (host string, port int, err error) { | |
| 254 | addr = strings.TrimSpace(addr) | |
| 255 | host, p, err := net.SplitHostPort(addr) | |
| 256 | if err != nil { | |
| 257 | return "", 0, fmt.Errorf("%s must be host:port, got %q: %v", name, addr, err) | |
| 258 | } | |
| 259 | n, err := strconv.Atoi(p) | |
| 260 | if err != nil || n < 0 || n > 65535 { | |
| 261 | return "", 0, fmt.Errorf("%s has an invalid port %q", name, p) | |
| 262 | } | |
| 263 | return host, n, nil | |
| 264 | } | |
| 265 | ||
| 266 | // loopbackHost is the single definition of "only reachable from this machine" | |
| 267 | // that both validation and the request check use. | |
| 268 | func loopbackHost(host string) bool { | |
| 269 | if strings.EqualFold(host, "localhost") { | |
| 270 | return true | |
| 271 | } | |
| 272 | ip := net.ParseIP(strings.Trim(host, "[]")) | |
| 273 | return ip != nil && ip.IsLoopback() | |
| 274 | } | |
| 275 | ||
| 276 | // validDNSSuffix accepts a bare lower-case DNS name. It is used for pages_domain, | |
| 277 | // where a visitor's Host header is matched against the suffix, so a value with a | |
| 278 | // slash or a port would silently match nothing. | |
| 279 | func validDNSSuffix(s string) bool { | |
| 280 | s = strings.TrimSpace(s) | |
| 281 | if s == "" || len(s) > 253 || strings.HasSuffix(s, ".") { | |
| 282 | return false | |
| 283 | } | |
| 284 | return dnsNameRE.MatchString(s) | |
| 285 | } | |
| 286 | ||
| 287 | // validRefName rejects a pages_ref git would refuse, plus the shapes that are | |
| 288 | // legal but never a branch: leading dash, dot components, control characters. | |
| 289 | func validRefName(name string) bool { | |
| 290 | name = strings.TrimSpace(name) | |
| 291 | if name == "" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || | |
| 292 | strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".lock") || | |
| 293 | strings.ContainsAny(name, " ~^:?*[\\") || strings.Contains(name, "..") || | |
| 294 | strings.Contains(name, "@{") { | |
| 295 | return false | |
| 296 | } | |
| 297 | for _, r := range name { | |
| 298 | if r < 0x20 || r == 0x7f { | |
| 299 | return false | |
| 300 | } | |
| 301 | } | |
| 302 | return true | |
| 303 | } |