Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect

- 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
This commit is contained in:
2026-10-30 19:27:49 +01:00
co-authored by Claude Sonnet 5.5
parent a32bf42777
commit caf2c6b26d
33 changed files with 1985 additions and 224 deletions
+38 -63
View File
@@ -4,23 +4,20 @@ import (
"errors"
"net/http"
"regexp"
"slices"
"strings"
)
// DDNSForm is the dynamic DNS section of the settings (router.ddns).
type DDNSForm struct {
Enabled bool
Protocol string
Server string
Username string
Secret string // write-only: empty keeps the stored one
SecretSet bool
Zone string
Domains string // one per line or comma separated
IPv4 bool
IPv6 bool
SSL bool
Interval string
Enabled bool
Provider string // a connection of router.dnsProviders
Zone string
Domains string // one per line or comma separated
IPv4 bool
IPv6 bool
SSL bool
Interval string
}
var (
@@ -40,30 +37,20 @@ func (s Settings) ddns() DDNSForm {
return def
}
f := DDNSForm{
Enabled: flag("enable", false), Protocol: str(d, "protocol"), Server: str(d, "server"),
Username: str(d, "username"), SecretSet: str(d, "passwordFile") != "", Zone: str(d, "zone"),
Enabled: flag("enable", false), Provider: str(d, "provider"), Zone: str(d, "zone"),
Domains: strings.Join(stringList(d, "domains"), "\n"), IPv4: flag("ipv4", true), IPv6: flag("ipv6", false),
SSL: flag("ssl", true), Interval: str(d, "interval"),
}
if f.Protocol == "" {
f.Protocol = "cloudflare"
}
if f.Username == "" && f.Protocol == "cloudflare" {
f.Username = "token"
}
if f.Interval == "" {
f.Interval = "5min"
}
return f
}
// applyDDNS writes the form into the settings. It returns the secret to store,
// if one was entered.
func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) {
// applyDDNS writes the form into the settings.
func (s Settings) applyDDNS(f DDNSForm) error {
errs := FormErrors{}
f.Protocol, f.Server = strings.ToLower(strings.TrimSpace(f.Protocol)), strings.TrimSpace(f.Server)
f.Username, f.Zone, f.Interval = strings.TrimSpace(f.Username), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval)
f.Secret = strings.TrimSpace(f.Secret)
f.Provider, f.Zone, f.Interval = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval)
var domains []any
for _, d := range splitList(f.Domains) {
if !ddnsHostRe.MatchString(d) {
@@ -72,36 +59,36 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) {
}
domains = append(domains, strings.ToLower(d))
}
if !protocolRe.MatchString(f.Protocol) {
errs["protocol"] = "the provider's ddclient protocol, like cloudflare or dyndns2"
}
if f.Server != "" && !serverRe.MatchString(f.Server) {
errs["server"] = "a host name, without http://"
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(ddnsTypes, str(p, "type")):
errs["provider"] = "dynamic DNS does not know " + str(p, "type") + "; it is for certificates"
case str(p, "secretFile") == "":
errs["provider"] = "this account has no password or token (add it on the DNS providers page)"
}
}
if f.Interval != "" && !intervalRe.MatchString(f.Interval) {
errs["interval"] = "like 5min, 30s or 1h"
}
if strings.ContainsAny(f.Username, "\n\r") {
errs["username"] = "one line"
}
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.IPv4 && !f.IPv6 {
errs["ipv4"] = "choose IPv4, IPv6 or both"
}
if f.Secret == "" && !s.ddns().SecretSet {
errs["secret"] = "the provider's password or API token"
}
}
if len(errs) > 0 {
return "", errs
return errs
}
d := obj(obj(s, "router"), "ddns")
d["enable"] = f.Enabled
d["protocol"] = f.Protocol
d["username"] = f.Username
d["ipv4"], d["ipv6"], d["ssl"] = f.IPv4, f.IPv6, f.SSL
if f.Interval != "" {
d["interval"] = f.Interval
@@ -113,17 +100,14 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) {
d[k] = v
}
}
set("server", f.Server)
set("provider", f.Provider)
set("zone", f.Zone)
if len(domains) > 0 {
d["domains"] = domains
} else {
delete(d, "domains")
}
if f.Secret != "" {
d["passwordFile"] = secretFile
}
return f.Secret, nil
return nil
}
// DDNSStatus is what ddclient last did.
@@ -141,9 +125,10 @@ func (s System) ddnsStatus() DDNSStatus {
}
type ddnsData struct {
Form DDNSForm
Errors FormErrors
Status DDNSStatus
Form DDNSForm
Errors FormErrors
Status DDNSStatus
Providers []DNSProvider
}
func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
@@ -154,7 +139,7 @@ func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "ddns", p)
return
}
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus()}
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
a.render(w, "ddns", p)
}
@@ -167,27 +152,17 @@ func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) {
return
}
v := r.PostFormValue
f := DDNSForm{Enabled: v("enabled") == "on", Protocol: v("protocol"), Server: v("server"), Username: v("username"),
Secret: v("secret"), Zone: v("zone"), Domains: v("domains"), IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on",
SSL: v("ssl") == "on", Interval: v("interval")}
secret, err := s.applyDDNS(f, a.sys.ddnsSecretPath())
if err != nil {
f := DDNSForm{Enabled: v("enabled") == "on", Provider: v("provider"), Zone: v("zone"), Domains: v("domains"),
IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on", SSL: v("ssl") == "on", Interval: v("interval")}
if err := s.applyDDNS(f); err != nil {
var fe FormErrors
errors.As(err, &fe)
f.Secret, f.SecretSet = "", s.ddns().SecretSet
p.Error = "Please correct the marked fields."
p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus()}
p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "ddns", p)
return
}
if secret != "" {
if err := writeFileAtomic(a.sys.ddnsSecretPath(), []byte(secret), 0o600); err != nil {
p.Error = "Storing the secret: " + err.Error()
a.render(w, "ddns", p)
return
}
}
if err := s.save(a.sys.settingsPath()); err != nil {
p.Error = "Saving: " + err.Error()
a.render(w, "ddns", p)