Files
nixos-router/pkgs/router-webui/certificate.go
nilsandClaude Sonnet 5.5 bda07ae163 Fold the certificate's order log away
The log of the last issuing run is only of interest when something went
wrong. It is a collapsed section now, open when the last run failed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 18:17:01 +01:00

266 lines
7.4 KiB
Go

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.")
}