package main import ( "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/pem" "errors" "log" "net/http" "os" "path/filepath" "slices" "strings" "sync" "time" ) // CertInfo describes the certificate the web UI serves. type CertInfo struct { Subject, Issuer string NotAfter time.Time SelfSigned bool Fingerprint string // SHA-256, colon separated } func readCertInfo(path string) (CertInfo, bool) { data, err := os.ReadFile(path) if err != nil { return CertInfo{}, false } block, _ := pem.Decode(data) if block == nil { return CertInfo{}, false } c, err := x509.ParseCertificate(block.Bytes) if err != nil { return CertInfo{}, false } sum := sha256.Sum256(c.Raw) hexSum := strings.ToUpper(hex.EncodeToString(sum[:])) var pairs []string for i := 0; i < len(hexSum); i += 2 { pairs = append(pairs, hexSum[i:i+2]) } return CertInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), NotAfter: c.NotAfter, SelfSigned: c.Subject.String() == c.Issuer.String(), Fingerprint: strings.Join(pairs, ":")}, true } // certSource serves the certificate an ACME client keeps in dir while it is // there and valid, and the self-signed one otherwise. Renewed files are picked // up on the next connection. type certSource struct { fallback tls.Certificate dir string mu sync.Mutex loaded tls.Certificate mod time.Time ok bool } func (c *certSource) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { if c.dir == "" { return &c.fallback, nil } chain, key := filepath.Join(c.dir, "fullchain.pem"), filepath.Join(c.dir, "key.pem") a, errA := os.Stat(chain) b, errB := os.Stat(key) c.mu.Lock() defer c.mu.Unlock() if errA != nil || errB != nil { c.ok = false return &c.fallback, nil } mod := a.ModTime() if b.ModTime().After(mod) { mod = b.ModTime() } if !c.ok || !mod.Equal(c.mod) { cert, err := tls.LoadX509KeyPair(chain, key) c.mod = mod // do not retry a broken pair on every connection c.ok = err == nil if err != nil { log.Printf("certificate in %s: %v", c.dir, err) } else { c.loaded = cert } } if !c.ok { return &c.fallback, nil } return &c.loaded, nil } // ACMEForm is the certificate section of the settings (router.acme). type ACMEForm struct { Enabled bool Provider string // a connection of router.dnsProviders Domains string // one per line or comma separated Email string Staging bool Agree bool } func (s Settings) acme() ACMEForm { router, _ := s["router"].(map[string]any) c, _ := router["acme"].(map[string]any) flag := func(k string, def bool) bool { if v, ok := c[k].(bool); ok { return v } return def } return ACMEForm{Enabled: flag("enable", false), Provider: str(c, "provider"), Domains: strings.Join(stringList(c, "domains"), "\n"), Email: str(c, "email"), Staging: flag("staging", true), Agree: flag("acceptTerms", false)} } func (s Settings) applyACME(f ACMEForm) error { errs := FormErrors{} f.Provider, f.Email = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Email) var domains []any for _, d := range splitList(f.Domains) { if !ddnsHostRe.MatchString(d) || !strings.Contains(d, ".") { errs["domains"] = d + " is not a host name" break } domains = append(domains, strings.ToLower(d)) } if f.Provider != "" { p, ok := s.dnsProviderMap()[f.Provider].(map[string]any) switch { case !ok: errs["provider"] = "choose one of the DNS providers" case !slices.Contains(acmeTypes, str(p, "type")): errs["provider"] = "lego has no DNS provider " + str(p, "type") + "; it is for dynamic DNS" case acmeTokens[str(p, "type")] != "" && str(p, "secretFile") == "": errs["provider"] = "this account has no token (add it on the DNS providers page)" case acmeTokens[str(p, "type")] == "" && str(p, "environmentFile") == "": errs["provider"] = "this account has no certificate variables (add them on the DNS providers page)" } } if f.Email != "" && !strings.Contains(f.Email, "@") { errs["email"] = "an e-mail address" } if f.Enabled { if f.Provider == "" { errs["provider"] = "choose a DNS provider (add one on the DNS providers page)" } if len(domains) == 0 { errs["domains"] = "name at least one host" } if f.Email == "" { errs["email"] = "an address Let's Encrypt can write to about expiry" } if !f.Agree { errs["agree"] = "accept the Let's Encrypt subscriber agreement" } } if len(errs) > 0 { return errs } c := obj(obj(s, "router"), "acme") c["enable"], c["staging"], c["acceptTerms"] = f.Enabled, f.Staging, f.Agree set := func(k, v string) { if v == "" { delete(c, k) } else { c[k] = v } } set("provider", f.Provider) set("email", f.Email) if len(domains) > 0 { c["domains"] = domains } else { delete(c, "domains") } return nil } type certificateData struct { Cert CertInfo Has bool Serving string // "acme" or "self-signed" Form ACMEForm Errors FormErrors Providers []DNSProvider Status string // the order's last result Failed bool // the last order did not succeed: the log is shown open Log string } // servedCert is what the web UI hands out: the ACME certificate if it is there. func (a *app) servedCert() (CertInfo, bool, string) { if a.sys.ACMEDir != "" { if c, ok := readCertInfo(filepath.Join(a.sys.ACMEDir, "fullchain.pem")); ok { return c, true, "acme" } } c, ok := readCertInfo(filepath.Join(a.sys.StateDir, "webui", "cert.pem")) return c, ok, "self-signed" } func (a *app) certificateData(s Settings, f ACMEForm, errs FormErrors) certificateData { var providers []DNSProvider for _, p := range a.dnsProviders(s) { if p.ACME { providers = append(providers, p) } } d := certificateData{Form: f, Errors: errs, Providers: providers} d.Cert, d.Has, d.Serving = a.servedCert() if first := splitList(f.Domains); len(first) > 0 && s.acme().Enabled { unit := "acme-order-renew-" + strings.ToLower(first[0]) + ".service" if st := unitProperty(unit, "ActiveState"); st != "" { res := unitProperty(unit, "Result") d.Status = st + ", result " + res d.Failed = res != "success" || st == "failed" d.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "15", "-u", unit) } } return d } func (a *app) certificatePage(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "certificate", p) return } p.Data = a.certificateData(s, s.acme(), nil) a.render(w, "certificate", p) } func (a *app) certificateSave(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "certificate", p) return } v := r.PostFormValue f := ACMEForm{Enabled: v("enabled") == "on", Provider: v("provider"), Domains: v("domains"), Email: v("email"), Staging: v("staging") == "on", Agree: v("agree") == "on"} if err := s.applyACME(f); err != nil { var fe FormErrors errors.As(err, &fe) p.Error = "Please correct the marked fields." p.Data = a.certificateData(s, f, fe) w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "certificate", p) return } if err := s.save(a.sys.settingsPath()); err != nil { p.Error = "Saving: " + err.Error() a.render(w, "certificate", p) return } back(w, r, "Saved. Apply to order the certificate; this page shows how it went.") }