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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 53m ago
4b8c9e1d
| 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 | |
| 24 | "rodent.software/vole/forge/access" |
| 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) |
| 108 | switch { |
| 109 | case err != nil: |
| 110 | add("open_registration_role: %v", err) |
| 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) |
| 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}, |
| 131 | {"repo_max_metadata_bytes", c.RepoMaxMetadataBytes}, |
| 132 | {"repo_max_bytes", c.RepoMaxBytes}, |
| 133 | {"repo_max_lfs_bytes", c.RepoMaxLFSBytes}, |
| 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 | } |
| 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 | } |
| 155 | if c.ContributorWritesPerHour < 0 { |
| 156 | add("contributor_writes_per_hour must not be negative, got %d", c.ContributorWritesPerHour) |
| 157 | } |
| 158 | if c.PagesProcs < 0 || c.PagesProcs > 64 { |
| 159 | add("pages_procs must be between 0 and 64, got %d", c.PagesProcs) |
| 160 | } |
| 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 | } |
| 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 | } |
| 304 |