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:
2026-10-30 13:35:57 +01:00
co-authored by Claude Sonnet 5.5
parent c198ffbe37
commit a36cebc827
19 changed files with 732 additions and 8 deletions
+21
View File
@@ -16,6 +16,7 @@ import (
"net/netip"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
@@ -67,6 +68,8 @@ type UplinkStatus struct {
type SystemStatus struct {
Uptime time.Duration
Load string
Kernel string // the running kernel
KernelNext string // the kernel of the current system when it differs: a reboot is due
CPU float64 // percent; NaN until the second sample
MemUsed uint64
MemTotal uint64
@@ -253,6 +256,12 @@ func systemStatus() SystemStatus {
s.Uptime = time.Duration(secs) * time.Second
}
}
if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
s.Kernel = strings.TrimSpace(string(data))
}
if dest, err := filepath.EvalSymlinks("/run/current-system/kernel"); err == nil {
s.KernelNext = pendingKernel(s.Kernel, dest)
}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
if f := strings.Fields(string(data)); len(f) >= 3 {
s.Load = strings.Join(f[:3], " ")
@@ -282,6 +291,18 @@ func systemStatus() SystemStatus {
return s
}
var kernelDirRe = regexp.MustCompile(`-linux-([0-9][^/]*)/`)
// pendingKernel is the version of the kernel image the current system boots
// (a path like /nix/store/…-linux-6.12.5/bzImage) if it is not the running one.
func pendingKernel(running, image string) string {
m := kernelDirRe.FindStringSubmatch(image)
if m == nil || running == "" || m[1] == running || strings.HasPrefix(running, m[1]+"-") {
return ""
}
return m[1]
}
// Sensor is one temperature the machine reports.
type Sensor struct {
Key string // stable name of its series
+197
View File
@@ -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.")
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"net/http"
"net/url"
"os"
"regexp"
"strings"
"testing"
)
func TestDDNSRoundTrip(t *testing.T) {
s := mustParse(t)
if f := s.ddns(); f.Enabled || f.Protocol != "cloudflare" || f.Username != "token" || f.Interval != "5min" || !f.IPv4 || f.IPv6 {
t.Fatalf("defaults = %+v", f)
}
f := DDNSForm{Enabled: true, Protocol: "Cloudflare", Username: "token", Secret: " s3cret\n", Zone: "example.org",
Domains: "Home.example.org, *.k8s.example.org\nvpn.example.org", IPv4: true, IPv6: true, SSL: true, Interval: "10min"}
secret, err := s.applyDDNS(f, "/var/lib/router/ddns-secret")
if err != nil || secret != "s3cret" {
t.Fatalf("secret=%q err=%v", secret, err)
}
out := string(mustMarshal(t, s))
for _, want := range []string{`"passwordFile": "/var/lib/router/ddns-secret"`, `"home.example.org"`, `"*.k8s.example.org"`, `"ipv6": true`, `"zone": "example.org"`} {
if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out)
}
}
if strings.Contains(out, "s3cret") {
t.Error("the secret is in the settings")
}
got := s.ddns()
if !got.SecretSet || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" || got.Protocol != "cloudflare" {
t.Errorf("read back = %+v", got)
}
// Saving again without a secret keeps the stored one; switching off keeps the rest.
f.Secret, f.Enabled = "", false
if secret, err := s.applyDDNS(f, "/x"); err != nil || secret != "" {
t.Fatalf("secret=%q err=%v", secret, err)
}
if got := s.ddns(); got.Enabled || !got.SecretSet || got.Zone != "example.org" {
t.Errorf("after switching off = %+v", got)
}
}
func TestDDNSIsChecked(t *testing.T) {
good := DDNSForm{Enabled: true, Protocol: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p",
Domains: "a.example.org", IPv4: true, SSL: true, Interval: "5min"}
for name, mod := range map[string]func(*DDNSForm){
"domains": func(f *DDNSForm) { f.Domains = "not a host!" },
"protocol": func(f *DDNSForm) { f.Protocol = "no way" },
"server": func(f *DDNSForm) { f.Server = "https://x.example" },
"interval": func(f *DDNSForm) { f.Interval = "soon" },
"ipv4": func(f *DDNSForm) { f.IPv4 = false },
"secret": func(f *DDNSForm) { f.Secret = "" },
} {
f := good
mod(&f)
if _, err := mustParse(t).applyDDNS(f, "/x"); err == nil || err.(FormErrors)[name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
f := good
f.Domains = ""
if _, err := mustParse(t).applyDDNS(f, "/x"); err == nil {
t.Error("enabled without names accepted")
}
f.Enabled, f.Secret = false, ""
if _, err := mustParse(t).applyDDNS(f, "/x"); err != nil {
t.Errorf("disabled needs neither names nor a secret: %v", err)
}
}
func TestDDNSPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/ddns", nil, cookie).Body.String()
if !strings.Contains(body, `name="domains"`) || !strings.Contains(body, "Dynamic DNS") {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "enabled": {"on"}, "protocol": {"cloudflare"}, "username": {"token"},
"zone": {"example.org"}, "domains": {"home.example.org"}, "ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
if rec := do(h, "POST", "/ddns", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "API token") {
t.Fatalf("no secret: %d\n%s", rec.Code, rec.Body)
}
form.Set("secret", "tok3n")
if rec := do(h, "POST", "/ddns", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
data, err := os.ReadFile(sys.ddnsSecretPath())
if err != nil || string(data) != "tok3n" {
t.Fatalf("secret file: %q %v", data, err)
}
if fi, _ := os.Stat(sys.ddnsSecretPath()); fi.Mode().Perm() != 0o600 {
t.Errorf("mode %v", fi.Mode())
}
s, _ := loadSettings(sys.settingsPath())
if f := s.ddns(); !f.Enabled || f.Domains != "home.example.org" || !f.SecretSet {
t.Fatalf("not saved: %+v", f)
}
if body := do(h, "GET", "/ddns", nil, cookie).Body.String(); strings.Contains(body, "tok3n") || !strings.Contains(body, "leave empty to keep") {
t.Errorf("the page shows the secret or lacks the hint:\n%s", body)
}
}
+3 -1
View File
@@ -243,7 +243,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "advanced", "update"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
@@ -292,6 +292,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
mux.HandleFunc("GET /ddns", a.auth(a.ddnsPage))
mux.HandleFunc("POST /ddns", a.auth(a.ddnsSave))
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
+15
View File
@@ -52,3 +52,18 @@ func TestReadSensors(t *testing.T) {
t.Errorf("no sensors: %+v (hottest should be NaN)", got)
}
}
func TestPendingKernel(t *testing.T) {
img := "/nix/store/abc123-linux-6.12.5/bzImage"
for running, want := range map[string]string{"6.12.5": "", "6.12.5-zen1": "", "6.12.4": "6.12.5", "": ""} {
if got := pendingKernel(running, img); got != want {
t.Errorf("running %q: %q, want %q", running, got, want)
}
}
if got := pendingKernel("6.12.4", "/boot/vmlinuz"); got != "" {
t.Errorf("an unknown path gave %q", got)
}
if k := systemStatus().Kernel; k == "" {
t.Skip("no /proc/sys/kernel/osrelease")
}
}
+5 -4
View File
@@ -31,10 +31,11 @@ func (s System) leasesDNSDir() string {
return "/run/router-leases-dns"
}
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
func (s System) ddnsSecretPath() string { return filepath.Join(s.StateDir, "ddns-secret") }
func command(name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -33,6 +33,7 @@
<h2>System</h2>
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span>{{end}}</dd>{{end}}
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
+50
View File
@@ -0,0 +1,50 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}{{$f := .Form}}
<p class="hint">Keeps DNS records at your provider pointing at the router's internet addresses: it checks regularly and
right away when the address changes (a new PPPoE session, a new lease). Uses
<a href="https://ddclient.net/protocols.html">ddclient</a>; one provider account.</p>
<form method="post" action="/ddns">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Provider</legend>
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Keep these names up to date</label>
<div>
<label>Provider (protocol) <input name="protocol" value="{{$f.Protocol}}" size="16" list="protocols" spellcheck="false"></label>
<datalist id="protocols"><option value="cloudflare"><option value="duckdns"><option value="dyndns2"><option value="namecheap"><option value="porkbun"><option value="hetzner"><option value="godaddy"><option value="digitalocean"></datalist>
<label>Server <input name="server" value="{{$f.Server}}" size="26" spellcheck="false" placeholder="only for dyndns2"></label>
<label>Zone <input name="zone" value="{{$f.Zone}}" size="22" spellcheck="false" placeholder="example.org (Cloudflare)"></label>
</div>
{{range $k := list "protocol" "server" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<div>
<label>Login <input name="username" value="{{$f.Username}}" size="18" spellcheck="false" autocomplete="off"></label>
<label>Password or API token <input type="password" name="secret" size="30" autocomplete="new-password"
placeholder="{{if $f.SecretSet}}stored; leave empty to keep{{else}}required{{end}}"></label>
</div>
{{with index $e "secret"}}<p class="field-error">{{.}}</p>{{end}}
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). The secret is kept on the router
in a private file, not in the settings.</p>
</fieldset>
<fieldset>
<legend>Names</legend>
<label>Host names, one per line <textarea name="domains" rows="6" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
<div>
<label class="inline"><input type="checkbox" name="ipv4" {{if $f.IPv4}}checked{{end}}> IPv4 (A records)</label>
<label class="inline"><input type="checkbox" name="ipv6" {{if $f.IPv6}}checked{{end}}> IPv6 (AAAA records)</label>
<label class="inline"><input type="checkbox" name="ssl" {{if $f.SSL}}checked{{end}}> HTTPS to the provider</label>
<label>Check every <input name="interval" value="{{$f.Interval}}" size="6"></label>
</div>
{{range $k := list "ipv4" "interval"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
</fieldset>
<button>Save</button>
</form>
<section>
<h2>Last updates</h2>
{{if .Status.Log}}<p class="muted">ddclient: {{or .Status.State "unknown"}}{{if .Status.Last}}, last run {{.Status.Last}}{{end}}</p>
<pre class="log">{{.Status.Log}}</pre>
{{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}}
</section>
{{end}}
{{end}}
+1
View File
@@ -18,6 +18,7 @@
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
<a href="/ddns">Dynamic DNS</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>