Files
nilsandClaude Sonnet 5.5 caf2c6b26d 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
2026-10-30 19:27:49 +01:00

212 lines
9.5 KiB
Go

package main
import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// withProvider adds a connection to the settings.
func withProvider(s Settings, name, typ string) Settings {
obj(obj(s, "router"), "dnsProviders")[name] = map[string]any{"type": typ, "secretFile": "/var/lib/router/dns-secrets/" + name}
return s
}
func TestDDNSRoundTrip(t *testing.T) {
s := withProvider(mustParse(t), "cf", "cloudflare")
if f := s.ddns(); f.Enabled || f.Provider != "" || f.Interval != "5min" || !f.IPv4 || f.IPv6 {
t.Fatalf("defaults = %+v", f)
}
f := DDNSForm{Enabled: true, Provider: "cf", Zone: "example.org",
Domains: "Home.example.org, *.k8s.example.org\nvpn.example.org", IPv4: true, IPv6: true, SSL: true, Interval: "10min"}
if err := s.applyDDNS(f); err != nil {
t.Fatal(err)
}
out := string(mustMarshal(t, s))
for _, want := range []string{`"provider": "cf"`, `"home.example.org"`, `"*.k8s.example.org"`, `"ipv6": true`, `"zone": "example.org"`} {
if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out)
}
}
got := s.ddns()
if got.Provider != "cf" || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" {
t.Errorf("read back = %+v", got)
}
f.Enabled = false // switching off keeps the rest
if err := s.applyDDNS(f); err != nil {
t.Fatal(err)
}
if got := s.ddns(); got.Enabled || got.Zone != "example.org" || got.Provider != "cf" {
t.Errorf("after switching off = %+v", got)
}
}
func TestDDNSIsChecked(t *testing.T) {
good := DDNSForm{Enabled: true, Provider: "cf", 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!" },
"provider": func(f *DDNSForm) { f.Provider = "nothing" },
"interval": func(f *DDNSForm) { f.Interval = "soon" },
"ipv4": func(f *DDNSForm) { f.IPv4 = false },
} {
f := good
mod(&f)
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)[name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
f := good
f.Provider = ""
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)["provider"] == "" {
t.Errorf("enabled without an account accepted: %v", err)
}
f = good
f.Domains = ""
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil {
t.Error("enabled without names accepted")
}
f.Enabled, f.Provider = false, ""
if err := mustParse(t).applyDDNS(f); err != nil {
t.Errorf("disabled needs neither names nor an account: %v", err)
}
}
func TestDNSProvidersRoundTrip(t *testing.T) {
s := mustParse(t)
dir := t.TempDir()
rows := []DNSProvider{
{Name: "CF", Type: "Cloudflare", Username: "token", Secret: " s3cret\n", New: true},
{Name: "dyn", Type: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p", New: true},
{New: true, Type: "cloudflare"}, // the empty add row
}
writes, deletes, err := s.applyDNSProviders(rows, dir)
if err != nil || len(writes) != 2 || len(deletes) != 0 {
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
}
if writes[0].Path != filepath.Join(dir, "cf") || writes[0].Secret != "s3cret" {
t.Errorf("writes = %+v", writes)
}
out := string(mustMarshal(t, s))
if strings.Contains(out, "s3cret") || !strings.Contains(out, `"secretFile": "`+dir+`/cf"`) || !strings.Contains(out, `"server": "dynupdate.no-ip.com"`) {
t.Errorf("settings:\n%s", out)
}
// Edited without a new secret: the file stays. A used connection cannot go.
obj(obj(s, "router"), "ddns")["provider"] = "cf"
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Username: "token"}, {Name: "dyn", Type: "dyndns2", Remove: true}}
if writes, deletes, err = s.applyDNSProviders(rows, dir); err != nil || len(writes) != 0 || len(deletes) != 1 {
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
}
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Remove: true}}
if _, _, err = s.applyDNSProviders(rows, dir); err == nil || !strings.Contains(err.(FormErrors)["prov[0].name"], "Dynamic DNS") {
t.Errorf("a connection in use was removed: %v", err)
}
}
func TestDNSProvidersAreChecked(t *testing.T) {
good := DNSProvider{Name: "cf", Type: "cloudflare", Secret: "x", New: true}
for name, mod := range map[string]func(*DNSProvider){
"name": func(r *DNSProvider) { r.Name = "Not A Name!" },
"type": func(r *DNSProvider) { r.Type = "no way" },
"server": func(r *DNSProvider) { r.Server = "https://x.example" },
"secret": func(r *DNSProvider) { r.Secret = "" },
} {
r := good
mod(&r)
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{r}, "/x"); err == nil || err.(FormErrors)["prov[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
s := withProvider(mustParse(t), "cf", "cloudflare")
if _, _, err := s.applyDNSProviders([]DNSProvider{good}, "/x"); err == nil {
t.Error("a new connection with a taken name accepted")
}
}
func TestDNSProvidersAndDDNSPages(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", "/dnsproviders", nil, cookie).Body.String()
if !strings.Contains(body, `name="providers" value="1"`) {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.new": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.username": {"token"}}
if rec := do(h, "POST", "/dnsproviders", 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("prov.0.secret", "tok3n")
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
path := filepath.Join(sys.StateDir, "dns-secrets", "cf")
data, err := os.ReadFile(path)
if err != nil || string(data) != "tok3n" {
t.Fatalf("secret file: %q %v", data, err)
}
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
t.Errorf("mode %v", fi.Mode())
}
if body := do(h, "GET", "/dnsproviders", 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)
}
// Dynamic DNS picks the connection.
body = do(h, "GET", "/ddns", nil, cookie).Body.String()
if !strings.Contains(body, `<option value="cf"`) {
t.Fatalf("the account is not offered:\n%s", body)
}
token = regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
dd := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "zone": {"example.org"}, "domains": {"home.example.org"},
"ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
if rec := do(h, "POST", "/ddns", dd, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if f := s.ddns(); !f.Enabled || f.Provider != "cf" || f.Domains != "home.example.org" {
t.Fatalf("not saved: %+v", f)
}
// ... and then it cannot be removed.
form = url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.remove": {"on"}}
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "used by Dynamic DNS") {
t.Fatalf("remove: %d\n%s", rec.Code, rec.Body)
}
}
func TestEnvironmentProviders(t *testing.T) {
s := mustParse(t)
dir := t.TempDir()
rows := []DNSProvider{{Name: "aws", Type: "route53", Env: "AWS_ACCESS_KEY_ID=a\r\nAWS_SECRET_ACCESS_KEY=b\n", New: true}}
writes, _, err := s.applyDNSProviders(rows, dir)
if err != nil || len(writes) != 1 || writes[0].Path != filepath.Join(dir, "aws.env") || writes[0].Secret != "AWS_ACCESS_KEY_ID=a\nAWS_SECRET_ACCESS_KEY=b\n" {
t.Fatalf("writes=%+v err=%v", writes, err)
}
out := string(mustMarshal(t, s))
if !strings.Contains(out, `"environmentFile"`) || strings.Contains(out, "secretFile") || strings.Contains(out, "AWS_SECRET") {
t.Errorf("settings:\n%s", out)
}
// Certificates accept it; dynamic DNS does not know route53.
good := ACMEForm{Enabled: true, Provider: "aws", Domains: "r.example.org", Email: "a@b.c", Agree: true}
if err := s.applyACME(good); err != nil {
t.Errorf("certificate through route53: %v", err)
}
if err := s.applyDDNS(DDNSForm{Enabled: true, Provider: "aws", Domains: "a.example.org", IPv4: true}); err == nil || err.(FormErrors)["provider"] == "" {
t.Errorf("dynamic DNS through route53 accepted: %v", err)
}
// A single-token provider needs the token, not variables.
cf := withProvider(mustParse(t), "cf", "cloudflare")
delete(cf.dnsProviderMap()["cf"].(map[string]any), "secretFile")
if err := cf.applyACME(ACMEForm{Enabled: true, Provider: "cf", Domains: "r.example.org", Email: "a@b.c", Agree: true}); err == nil {
t.Error("cloudflare without a token accepted")
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "route53", Env: "not variables", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].env"] == "" {
t.Errorf("bad variables accepted: %v", err)
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "nonesuch", Secret: "s", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].type"] == "" {
t.Errorf("an unknown provider accepted: %v", err)
}
}