- router.dnsProviders: named accounts at DNS providers, used by dynamic DNS and certificates (ddclient and lego provider lists in pkgs/router-webui/providers.json). Schema 2 migrates router.ddns. - router.acme: a Let's Encrypt certificate for the web UI via security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the web UI; Certificate page. - Web UI: password change (web UI and root), reboot button, HTTP to HTTPS redirect (router.webui.httpPort), grouped menu. - Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel for the reboot hint. - SPECS.md: wanted later (REST API, GraphQL, MCP). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
263 lines
7.3 KiB
Go
263 lines
7.3 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
|
|
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 != "" {
|
|
d.Status = st + ", result " + unitProperty(unit, "Result")
|
|
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.")
|
|
}
|