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
107 lines
4.3 KiB
Go
107 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|