- 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
173 lines
4.9 KiB
Go
173 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// DDNSForm is the dynamic DNS section of the settings (router.ddns).
|
|
type DDNSForm struct {
|
|
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 (
|
|
protocolRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
|
ddnsHostRe = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
|
intervalRe = regexp.MustCompile(`^[0-9]+ ?(s|sec|m|min|h)$`)
|
|
serverRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$`)
|
|
)
|
|
|
|
func (s Settings) ddns() DDNSForm {
|
|
router, _ := s["router"].(map[string]any)
|
|
d, _ := router["ddns"].(map[string]any)
|
|
flag := func(k string, def bool) bool {
|
|
if v, ok := d[k].(bool); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
f := DDNSForm{
|
|
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.Interval == "" {
|
|
f.Interval = "5min"
|
|
}
|
|
return f
|
|
}
|
|
|
|
// applyDDNS writes the form into the settings.
|
|
func (s Settings) applyDDNS(f DDNSForm) error {
|
|
errs := FormErrors{}
|
|
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) {
|
|
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(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 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 len(errs) > 0 {
|
|
return errs
|
|
}
|
|
d := obj(obj(s, "router"), "ddns")
|
|
d["enable"] = f.Enabled
|
|
d["ipv4"], d["ipv6"], d["ssl"] = f.IPv4, f.IPv6, f.SSL
|
|
if f.Interval != "" {
|
|
d["interval"] = f.Interval
|
|
}
|
|
set := func(k, v string) {
|
|
if v == "" {
|
|
delete(d, k)
|
|
} else {
|
|
d[k] = v
|
|
}
|
|
}
|
|
set("provider", f.Provider)
|
|
set("zone", f.Zone)
|
|
if len(domains) > 0 {
|
|
d["domains"] = domains
|
|
} else {
|
|
delete(d, "domains")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DDNSStatus is what ddclient last did.
|
|
type DDNSStatus struct {
|
|
State string // "inactive", "active", ... of ddclient.service
|
|
Last string // when it last ran
|
|
Log string
|
|
}
|
|
|
|
func (s System) ddnsStatus() DDNSStatus {
|
|
st := DDNSStatus{State: unitProperty("ddclient.service", "ActiveState"),
|
|
Last: unitProperty("ddclient.service", "ExecMainExitTimestamp")}
|
|
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "20", "-u", "ddclient.service")
|
|
return st
|
|
}
|
|
|
|
type ddnsData struct {
|
|
Form DDNSForm
|
|
Errors FormErrors
|
|
Status DDNSStatus
|
|
Providers []DNSProvider
|
|
}
|
|
|
|
func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
|
|
p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true}
|
|
s, err := loadSettings(a.sys.settingsPath())
|
|
if err != nil {
|
|
p.Error = err.Error()
|
|
a.render(w, "ddns", p)
|
|
return
|
|
}
|
|
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
|
|
a.render(w, "ddns", p)
|
|
}
|
|
|
|
func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) {
|
|
p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true}
|
|
s, err := loadSettings(a.sys.settingsPath())
|
|
if err != nil {
|
|
p.Error = err.Error()
|
|
a.render(w, "ddns", p)
|
|
return
|
|
}
|
|
v := r.PostFormValue
|
|
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)
|
|
p.Error = "Please correct the marked fields."
|
|
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 err := s.save(a.sys.settingsPath()); err != nil {
|
|
p.Error = "Saving: " + err.Error()
|
|
a.render(w, "ddns", p)
|
|
return
|
|
}
|
|
back(w, r, "Saved. Apply to make the changes take effect.")
|
|
}
|