- 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
212 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|