Add dynamic DNS and show the running kernel on the overview
router.ddns keeps provider records on the uplink's addresses with ddclient, run by a timer and by a watcher that starts it when an address changes. The Dynamic DNS page edits it; the secret is a private file on the router. The overview shows the running kernel and says when the current system boots a different one. PLAN.md records why the A/B image approach was not pursued. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"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
|
||||
}
|
||||
|
||||
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), Protocol: str(d, "protocol"), Server: str(d, "server"),
|
||||
Username: str(d, "username"), SecretSet: str(d, "passwordFile") != "", 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) {
|
||||
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)
|
||||
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 !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.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 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
|
||||
}
|
||||
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
|
||||
}
|
||||
set := func(k, v string) {
|
||||
if v == "" {
|
||||
delete(d, k)
|
||||
} else {
|
||||
d[k] = v
|
||||
}
|
||||
}
|
||||
set("server", f.Server)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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()}
|
||||
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", 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 {
|
||||
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()}
|
||||
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)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
Reference in New Issue
Block a user