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/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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 53m ago 4b8c9e1d
history blame raw 6.6 KiB
1package server
2
3import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "fmt"
8 "net"
9 "net/http"
10 "net/url"
11 "rodent.software/vole/internal/secure"
12 "sort"
13 "strings"
14
15 "golang.org/x/crypto/acme"
16 "golang.org/x/crypto/acme/autocert"
17 "rodent.software/vole/forge/config"
18 "rodent.software/vole/forge/repo"
19)
20
21// acmeSetup owns the certificates for the HTTPS listener. Certificates are
22// obtained on demand from an ACME certificate authority and reused from a
23// directory cache, so a restart does not ask for them again.
24type acmeSetup struct {
25 manager *autocert.Manager
26 hosts map[string]bool
27 // pages is the site suffix. A host under it is eligible for a certificate
28 // when it names a repository that is publicly readable, which keeps an
29 // arbitrary Host header (or a private repository's name) from asking the
30 // certificate authority for a new name.
31 pages string
32 site func(string) bool
33 httpsPort string
34}
35
36// newACME builds the certificate manager when https_addr is set. It returns nil
37// for an instance that lets a proxy terminate TLS. sitePublic reports whether a
38// repository is publicly readable, for site hostnames.
39func newACME(cfg *config.Config, sitePublic func(string) bool) (*acmeSetup, error) {
40 if strings.TrimSpace(cfg.HTTPSAddr) == "" {
41 return nil, nil
42 }
43
44 hosts := make(map[string]bool)
45 for _, raw := range cfg.ACMEDomains {
46 host, err := certificateHost(raw)
47 if err != nil {
48 return nil, fmt.Errorf("acme_domains: %w", err)
49 }
50 hosts[host] = true
51 }
52 if strings.TrimSpace(cfg.PublicURL) != "" {
53 u, err := url.Parse(strings.TrimSpace(cfg.PublicURL))
54 if err != nil || u.Hostname() == "" {
55 return nil, fmt.Errorf("https_addr requires public_url to name a host: %q", cfg.PublicURL)
56 }
57 if u.Scheme != "https" {
58 return nil, fmt.Errorf("https_addr requires an https public_url, got %q", cfg.PublicURL)
59 }
60 host, err := certificateHost(u.Hostname())
61 if err != nil {
62 return nil, fmt.Errorf("public_url: %w", err)
63 }
64 hosts[host] = true
65 }
66 if len(hosts) == 0 {
67 return nil, errors.New("https_addr is set but there is no certificate host: set public_url or acme_domains")
68 }
69
70 a := &acmeSetup{
71 hosts: hosts,
72 site: sitePublic,
73 httpsPort: listenPort(cfg.HTTPSAddr),
74 }
75 if strings.TrimSpace(cfg.PagesDomain) != "" {
76 domain, err := certificateHost(cfg.PagesDomain)
77 if err != nil {
78 return nil, fmt.Errorf("pages_domain: %w", err)
79 }
80 a.pages = domain
81 }
82
83 cachePath := cfg.ACMECachePath()
84 // The cache holds the ACME account key. Create it up front so a permissions
85 // mistake is a startup error, not a failed handshake the first time somebody
86 // visits.
87 if err := secure.Mkdir(cachePath); err != nil {
88 return nil, fmt.Errorf("acme cache %s: %w", cachePath, err)
89 }
90 a.manager = &autocert.Manager{
91 Prompt: autocert.AcceptTOS,
92 Cache: autocert.DirCache(cachePath),
93 HostPolicy: a.allows,
94 Email: strings.TrimSpace(cfg.ACMEEmail),
95 }
96 if directory := strings.TrimSpace(cfg.ACMEDirectoryURL); directory != "" {
97 u, err := url.Parse(directory)
98 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
99 return nil, fmt.Errorf("acme_directory_url %q is not an http(s) URL", directory)
100 }
101 a.manager.Client = &acme.Client{DirectoryURL: directory}
102 }
103 return a, nil
104}
105
106// tlsConfig is suitable for http.Server.ServeTLS. The manager answers
107// tls-alpn-01 challenges through the same listener that serves the forge.
108func (a *acmeSetup) tlsConfig() *tls.Config {
109 cfg := a.manager.TLSConfig()
110 cfg.MinVersion = tls.VersionTLS12
111 return cfg
112}
113
114// httpHandler answers ACME HTTP-01 challenges and sends every other request to
115// the HTTPS listener. Keeping it on a real HTTP listener also makes a blocked
116// tls-alpn-01 challenge recoverable through HTTP-01.
117func (a *acmeSetup) httpHandler() http.Handler {
118 return a.manager.HTTPHandler(http.HandlerFunc(a.redirectToHTTPS))
119}
120
121func (a *acmeSetup) redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
122 if r.Method != http.MethodGet && r.Method != http.MethodHead {
123 http.Error(w, "use HTTPS", http.StatusBadRequest)
124 return
125 }
126 host := requestHost(r.Host)
127 if err := a.allows(r.Context(), host); err != nil {
128 http.NotFound(w, r)
129 return
130 }
131 if a.httpsPort != "" && a.httpsPort != "443" {
132 host = net.JoinHostPort(host, a.httpsPort)
133 }
134 http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently)
135}
136
137// allows is the autocert HostPolicy. Exact names come from public_url and
138// acme_domains. Site names are allowed only while the repository they name is
139// publicly readable.
140func (a *acmeSetup) allows(_ context.Context, host string) error {
141 host = requestHost(host)
142 if a.hosts[host] {
143 return nil
144 }
145 if a.pages != "" {
146 suffix := "." + a.pages
147 if strings.HasSuffix(host, suffix) {
148 name := strings.TrimSuffix(host, suffix)
149 if repo.ValidateName(name) == nil && (a.site == nil || a.site(name)) {
150 return nil
151 }
152 }
153 }
154 return fmt.Errorf("acme: host %q is not configured for automatic certificates", host)
155}
156
157// hostNames is the configured certificate set, sorted for a startup log.
158func (a *acmeSetup) hostNames() []string {
159 out := make([]string, 0, len(a.hosts))
160 for host := range a.hosts {
161 out = append(out, host)
162 }
163 sort.Strings(out)
164 return out
165}
166
167func certificateHost(raw string) (string, error) {
168 host := normalizeHost(raw)
169 if host == "" {
170 return "", errors.New("empty host name")
171 }
172 if strings.Contains(host, "*") {
173 return "", fmt.Errorf("%q: wildcards are not supported; name each host", raw)
174 }
175 if strings.Contains(host, "://") || strings.ContainsAny(host, "/:@ \t") {
176 return "", fmt.Errorf("%q: use a bare host name, not a URL or address", raw)
177 }
178 if net.ParseIP(host) != nil {
179 return "", fmt.Errorf("%q: IP addresses are not supported", raw)
180 }
181 if !strings.Contains(host, ".") {
182 return "", fmt.Errorf("%q: use a fully qualified domain name", raw)
183 }
184 for _, label := range strings.Split(host, ".") {
185 if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
186 return "", fmt.Errorf("%q: invalid DNS name", raw)
187 }
188 for _, r := range label {
189 if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
190 return "", fmt.Errorf("%q: invalid DNS name", raw)
191 }
192 }
193 }
194 return host, nil
195}
196
197func normalizeHost(host string) string {
198 return strings.TrimRight(strings.ToLower(strings.TrimSpace(host)), ".")
199}
200
201// requestHost drops a port from a Host header without treating an IPv6 literal
202// as a host and port pair.
203func requestHost(hostport string) string {
204 if host, _, err := net.SplitHostPort(hostport); err == nil {
205 return normalizeHost(host)
206 }
207 return normalizeHost(hostport)
208}
209
210func listenPort(addr string) string {
211 _, port, err := net.SplitHostPort(addr)
212 if err != nil {
213 return ""
214 }
215 return port
216}
217