Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 57m ago 32290114
history blame raw 11.8 KiB
1package 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
15import (
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.
30var 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.
34var 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.
38var 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.
43func (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.
218func 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.
253func 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.
268func 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.
279func 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.
289func 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