From caf2c6b26d12e2cad7cc07f5d90ecddc27a938c4 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Fri, 30 Oct 2026 19:27:49 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- PLAN.md | 6 + SPECS.md | 16 + migrations/2.jq | 19 + modules/router/acme.nix | 73 ++++ modules/router/ddns.nix | 36 +- modules/router/default.nix | 1 + modules/router/options.nix | 128 +++++-- modules/router/power.nix | 11 +- modules/settings/default.nix | 92 +++-- pkgs/router-webui/certificate.go | 262 ++++++++++++++ pkgs/router-webui/collect.go | 7 +- pkgs/router-webui/ddns.go | 101 ++---- pkgs/router-webui/ddns_test.go | 187 +++++++--- pkgs/router-webui/dnsproviders.go | 331 ++++++++++++++++++ pkgs/router-webui/main.go | 88 ++++- pkgs/router-webui/main_test.go | 143 ++++++++ pkgs/router-webui/package.nix | 1 + pkgs/router-webui/providers.json | 272 ++++++++++++++ pkgs/router-webui/sensors_test.go | 5 +- pkgs/router-webui/static/menu.js | 13 + pkgs/router-webui/static/style.css | 6 + pkgs/router-webui/system.go | 40 ++- pkgs/router-webui/templates/advanced.html | 18 + pkgs/router-webui/templates/certificate.html | 45 +++ pkgs/router-webui/templates/dashboard.html | 2 +- pkgs/router-webui/templates/ddns.html | 18 +- pkgs/router-webui/templates/dnsproviders.html | 36 ++ pkgs/router-webui/templates/layout.html | 25 +- scripts/webui-dev.sh | 4 +- tests/assertions.nix | 148 +++++++- tests/ddns.nix | 15 +- tests/update.nix | 22 +- tests/webui.nix | 38 +- 33 files changed, 1985 insertions(+), 224 deletions(-) create mode 100644 SPECS.md create mode 100644 migrations/2.jq create mode 100644 modules/router/acme.nix create mode 100644 pkgs/router-webui/certificate.go create mode 100644 pkgs/router-webui/dnsproviders.go create mode 100644 pkgs/router-webui/providers.json create mode 100644 pkgs/router-webui/static/menu.js create mode 100644 pkgs/router-webui/templates/certificate.html create mode 100644 pkgs/router-webui/templates/dnsproviders.html diff --git a/PLAN.md b/PLAN.md index bd38acf..90b164e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -114,6 +114,12 @@ Learned along the way: - IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone. - Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit). +## DNS accounts, dynamic DNS and certificates + +- `router.dnsProviders` holds accounts at DNS providers by name (`type`, `username`, `server`, `secretFile`); the token is a private file (web UI: `/var/lib/router/dns-secrets/NAME`), never in settings or the store. `router.ddns.provider` and `router.acme.provider` refer to one. Schema 2 (`migrations/2.jq`) moves the old `ddns.protocol/server/username/passwordFile` into an account named after the protocol. +- `router.acme`: a Let's Encrypt certificate for the web UI through `security.acme` (lego), DNS-01 only (no open port, wildcards work), with providers that have one token: cloudflare, desec, digitalocean, duckdns, hetzner. Staging by default; `acceptTerms` must be set. The web UI serves `/var/lib/acme//{fullchain,key}.pem` when present (reloaded when the files change, so renewals need no restart) and its self-signed certificate otherwise. +- Tested: assertions, the web UI forms (Go and VM), the certificate source and the migration. **Not tested: an order against Let's Encrypt** (needs a pebble test server or a real zone). + ## Deployment and rollback (confirmed) - **Router on a stick**: `wan.interface` may also carry LANs, but only for PPPoE on a VLAN with IPv6 disabled (the untagged link is then free for a LAN; native IPv6 needs an untagged uplink). In that mode only the PPP device is an uplink in the firewall, so LAN traffic can never match WAN rules; the WAN VLAN is created from the LAN's/trunk's networkd unit. A LAN VLAN may not reuse the PPPoE VLAN id. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..9a273d1 --- /dev/null +++ b/SPECS.md @@ -0,0 +1,16 @@ +# Specs + +What nixos-router should become, beyond what exists. `PLAN.md` records decisions and what is done; this file +lists wanted features. Nothing here is started. + +## Later + +### Programmatic access + +- **REST API** for everything the web UI does (settings, apply/confirm, status, metrics, updates), versioned and + authenticated with tokens rather than the browser session. The web UI would use the same handlers. +- **GraphQL** on top of it, possibly: one query for the overview and for related settings. +- **MCP server**, so an assistant can read the router's state and propose or make changes (the same checks as the + web UI: settings are validated, applied with commit-confirm rollback, secrets are never returned). +- To settle first: token scopes (read-only vs. change), audit log, and that changes still go through + `settings.json` and `router-apply` rather than touching the live system. diff --git a/migrations/2.jq b/migrations/2.jq new file mode 100644 index 0000000..c34d2ce --- /dev/null +++ b/migrations/2.jq @@ -0,0 +1,19 @@ +# The provider account of dynamic DNS (protocol, server, username, +# passwordFile) moves to `dnsProviders`, named after the protocol, so that +# certificates can use the same account. The secret file stays where it is. +if (.router.ddns // null) == null + or (.router.ddns.provider // null) != null + or ((.router.ddns | keys) - ["enable", "zone", "domains", "ipv4", "ipv6", "ssl", "interval"] | length) == 0 +then . +else + (.router.ddns.protocol // "cloudflare") as $name + | .router.dnsProviders = ((.router.dnsProviders // {}) + { + ($name): ( + {type: $name} + + (if .router.ddns.username != null then {username: .router.ddns.username} else {} end) + + (if .router.ddns.server != null then {server: .router.ddns.server} else {} end) + + (if .router.ddns.passwordFile != null then {secretFile: .router.ddns.passwordFile} else {} end) + )}) + | .router.ddns.provider = $name + | del(.router.ddns.protocol, .router.ddns.server, .router.ddns.username, .router.ddns.passwordFile) +end diff --git a/modules/router/acme.nix b/modules/router/acme.nix new file mode 100644 index 0000000..05a0d07 --- /dev/null +++ b/modules/router/acme.nix @@ -0,0 +1,73 @@ +# A Let's Encrypt certificate for the web UI, proven with a DNS record (DNS-01): +# no port needs to be open to the internet, and wildcards work. NixOS's +# security.acme (lego) orders and renews it; the web UI picks up the files. +{ + config, + lib, + ... +}: +let + cfg = config.router; + acme = cfg.acme; + + # Shared with the web UI: the providers lego knows, and those with a single + # token (the variable names the file holding it). The others take an + # environment file with whatever variables they need. + providers = builtins.fromJSON (builtins.readFile ../../pkgs/router-webui/providers.json); + tokenVariables = providers.acmeToken; + + prov = + cfg.dnsProviders.${if acme.provider == null then "" else acme.provider} or { + type = "none"; + secretFile = null; + environmentFile = null; + }; + token = tokenVariables ? ${prov.type}; + secret = if token then prov.secretFile else prov.environmentFile; + has = acme.provider != null && cfg.dnsProviders ? ${acme.provider}; + name = if acme.domains == [ ] then "unset" else lib.head acme.domains; +in +{ + config = lib.mkIf (cfg.enable && acme.enable) { + assertions = [ + { + assertion = acme.domains != [ ]; + message = "router.acme: no domains for the certificate."; + } + { + assertion = has; + message = "router.acme.provider must name a connection in router.dnsProviders."; + } + { + assertion = !has || lib.elem prov.type providers.acme; + message = "router.acme: lego has no DNS provider ${prov.type}."; + } + { + assertion = !has || !lib.elem prov.type providers.acme || (secret != null && !lib.hasPrefix "/nix/store" secret); + message = "router.dnsProviders.${toString acme.provider} needs ${if token then "secretFile" else "environmentFile"} for certificates, a file outside the Nix store (the secret must not be copied into it)."; + } + { + assertion = acme.email != null; + message = "router.acme.email: Let's Encrypt needs a contact address."; + } + { + assertion = acme.acceptTerms; + message = "router.acme.acceptTerms: the Let's Encrypt subscriber agreement must be accepted."; + } + ]; + + security.acme = { + # Ours is the assertion above. + acceptTerms = true; + defaults.email = acme.email; + certs.${name} = { + extraDomainNames = lib.tail acme.domains; + dnsProvider = prov.type; + inherit (acme) dnsResolver; + credentialFiles = lib.optionalAttrs token { ${tokenVariables.${prov.type}} = prov.secretFile; }; + environmentFile = lib.mkIf (!token) prov.environmentFile; + server = lib.mkIf acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory"; + }; + }; + }; +} diff --git a/modules/router/ddns.nix b/modules/router/ddns.nix index 4f69c8f..e7da34c 100644 --- a/modules/router/ddns.nix +++ b/modules/router/ddns.nix @@ -10,6 +10,16 @@ let cfg = config.router; ddns = cfg.ddns; + # The connection (an empty one when it is missing: the assertion says so). + providers = builtins.fromJSON (builtins.readFile ../../pkgs/router-webui/providers.json); + has = ddns.provider != null && cfg.dnsProviders ? ${ddns.provider}; + prov = + cfg.dnsProviders.${if ddns.provider == null then "" else ddns.provider} or { + type = "none"; + username = "token"; + server = null; + secretFile = null; + }; # Where the addresses are: IPv4 on the PPP device, IPv6 on the physical uplink # (or the PPP device when IPv6 runs over it). @@ -47,22 +57,26 @@ in message = "router.ddns: neither IPv4 nor IPv6 is enabled."; } { - assertion = ddns.passwordFile != null && !lib.hasPrefix "/nix/store" ddns.passwordFile; - message = "router.ddns.passwordFile must name a file outside the Nix store (the secret must not be copied into it)."; + assertion = has; + message = "router.ddns.provider must name a connection in router.dnsProviders."; + } + { + assertion = !has || lib.elem prov.type providers.ddns; + message = "router.ddns: ddclient has no protocol ${prov.type}."; + } + { + assertion = !has || (prov.secretFile != null && !lib.hasPrefix "/nix/store" prov.secretFile); + message = "The secretFile of router.dnsProviders.${toString ddns.provider} must name a file outside the Nix store (the secret must not be copied into it)."; } ]; services.ddclient = { enable = true; - inherit (ddns) - protocol - username - passwordFile - domains - ssl - interval - ; - server = lib.optionalString (ddns.server != null) ddns.server; + inherit (ddns) domains ssl interval; + protocol = prov.type; + inherit (prov) username; + passwordFile = prov.secretFile; + server = lib.optionalString (prov.server != null) prov.server; zone = lib.optionalString (ddns.zone != null) ddns.zone; usev4 = lib.optionalString ddns.ipv4 "ifv4, ifv4=${dev4}"; usev6 = lib.optionalString ddns.ipv6 "ifv6, ifv6=${dev6}"; diff --git a/modules/router/default.nix b/modules/router/default.nix index 2ac4769..47fde4d 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -9,6 +9,7 @@ ./dhcp.nix ./dns.nix ./ddns.nix + ./acme.nix ./rollback.nix ./power.nix ]; diff --git a/modules/router/options.nix b/modules/router/options.nix index 8c5bca3..61cef86 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -580,6 +580,111 @@ in ); }; + dnsProviders = mkOption { + default = { }; + description = '' + Accounts at DNS providers, by name. Dynamic DNS (`ddns.provider`) and + certificates (`acme.provider`) use them, so a token is kept once. + ''; + type = types.attrsOf ( + types.submodule { + options = { + type = mkOption { + type = types.str; + example = "cloudflare"; + description = '' + The provider. For dynamic DNS the ddclient protocol + (); for certificates the + provider code of lego (). + The names are the same where both know the provider. + The lists are in `pkgs/router-webui/providers.json`. + ''; + }; + username = mkOption { + type = types.str; + default = "token"; + description = "Login for dynamic DNS; `token` for an API token (Cloudflare)."; + }; + server = mkOption { + type = types.nullOr types.str; + default = null; + example = "dynupdate.no-ip.com"; + description = "The provider's update server, for protocols that need one (`dyndns2`)."; + }; + secretFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/router/dns-secrets/cloudflare"; + description = '' + File with the password or API token (dynamic DNS; for + certificates through cloudflare, desec, digitalocean, duckdns + and hetzner the same token). Never a path in the Nix store. + ''; + }; + environmentFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/router/dns-secrets/route53.env"; + description = '' + File with `VARIABLE=value` lines for lego, for certificates + through providers other than those above; the variables are + listed at . Never a path in the Nix store. + ''; + }; + }; + } + ); + }; + + acme = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + A certificate from Let's Encrypt for the web UI, proven by a DNS + record (DNS-01, no port open to the internet), renewed automatically. + ''; + }; + provider = mkOption { + type = types.nullOr types.str; + default = null; + example = "cloudflare"; + description = "The name of a connection in `dnsProviders` that can change the records of the domains."; + }; + domains = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "router.example.org" ]; + description = "Names in the certificate; the first is its name. A wildcard like `*.home.example.org` is possible."; + }; + email = mkOption { + type = types.nullOr types.str; + default = null; + description = "Where Let's Encrypt writes about expiring certificates."; + }; + dnsResolver = mkOption { + type = types.nullOr types.str; + default = "1.1.1.1:53"; + example = "9.9.9.9:53"; + description = '' + Resolver (`address:port`) that lego asks to find the zone's name + servers and to check that the challenge record has spread. Without + one it would use the router's own Unbound, which answers the local + domain itself and caches. `null`: the router's resolver. + ''; + }; + staging = mkOption { + type = types.bool; + default = true; + description = "Use the staging service: certificates that browsers do not trust, for trying it out."; + }; + acceptTerms = mkOption { + type = types.bool; + default = false; + description = "Accept the Let's Encrypt subscriber agreement."; + }; + }; + ddns = { enable = mkOption { type = types.bool; @@ -590,28 +695,11 @@ in uplink address changes. ''; }; - protocol = mkOption { - type = types.str; - default = "cloudflare"; - example = "dyndns2"; - description = "ddclient protocol of the provider, see ."; - }; - server = mkOption { + provider = mkOption { type = types.nullOr types.str; default = null; - example = "dynupdate.no-ip.com"; - description = "The provider's update server, for protocols that need one (`dyndns2`)."; - }; - username = mkOption { - type = types.str; - default = "token"; - description = "Login; `token` for a Cloudflare API token."; - }; - passwordFile = mkOption { - type = types.nullOr types.str; - default = null; - example = "/var/lib/router/ddns-secret"; - description = "File with the password or API token. Never a path in the Nix store."; + example = "cloudflare"; + description = "The name of a connection in `dnsProviders` to update the records through."; }; zone = mkOption { type = types.nullOr types.str; diff --git a/modules/router/power.nix b/modules/router/power.nix index 79048ca..39bd335 100644 --- a/modules/router/power.nix +++ b/modules/router/power.nix @@ -1,8 +1,15 @@ # A router keeps routing with the lid shut: laptops make fine routers, but -# logind suspends them on lid close by default. -{ config, lib, ... }: +# logind suspends them on lid close by default. Also picks the kernel. +{ + config, + lib, + pkgs, + ... +}: { config = lib.mkIf config.router.enable { + # Newest hardware support (and nftables/WireGuard fixes) over the LTS default. + boot.kernelPackages = lib.mkDefault pkgs.linuxPackages_latest; services.logind.settings.Login = { HandleLidSwitch = "ignore"; HandleLidSwitchExternalPower = "ignore"; diff --git a/modules/settings/default.nix b/modules/settings/default.nix index 8dcdff3..fd8c476 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -94,6 +94,11 @@ in type = lib.types.port; default = 443; }; + httpPort = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = 80; + description = "Redirect plain HTTP on this port to HTTPS (to the LANs and tunnels with `allowManagement`); null: do not listen."; + }; }; }; @@ -105,7 +110,12 @@ in if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi ''; } - (lib.mkIf cfg.webui.enable { managementPorts = [ cfg.webui.port ]; }) + (lib.mkIf cfg.webui.enable { + managementPorts = [ + cfg.webui.port + ] + ++ lib.optional (cfg.webui.httpPort != null) cfg.webui.httpPort; + }) ]; networking.hostName = system.hostName or "router"; @@ -143,6 +153,19 @@ in TimeoutStartSec = "1h"; }; }; + # Puts root's password hash (written by the web UI) into /etc/shadow now, + # rather than at the next switch; the web UI itself may not touch /etc. + systemd.services.router-root-password = lib.mkIf cfg.webui.enable { + description = "Apply root's password from the web UI"; + restartIfChanged = false; + stopIfChanged = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "router-root-password" '' + exec ${pkgs.shadow}/bin/usermod --password "$(cat ${cfg.stateDirectory}/root-password)" root + ''; + }; + }; systemd.services.router-confirm = { description = "Confirm the active router configuration"; restartIfChanged = false; @@ -165,9 +188,16 @@ in pkgs.iproute2 pkgs.wireguard-tools pkgs.qrencode + pkgs.mkpasswd # the hash of root's password ]; serviceConfig = { - ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}"; + ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port}${ + lib.optionalString (cfg.webui.httpPort != null) " -http :${toString cfg.webui.httpPort}" + }${ + lib.optionalString ( + config.router.acme.enable && config.router.acme.domains != [ ] + ) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}" + } -config ${cfg.directory} -state ${cfg.stateDirectory}"; Restart = "on-failure"; # It edits settings and secrets and talks to systemd; nothing else. ProtectSystem = "strict"; @@ -192,34 +222,36 @@ in # The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's # leases and publishes them in the running Unbound. - systemd.services.router-leases-dns = lib.mkIf (config.router.enable && config.router.dns.dynamicLeases) { - description = "Publish DHCP clients' names in DNS"; - wantedBy = [ "multi-user.target" ]; - after = [ - "unbound.service" - "kea-dhcp4-server.service" - ]; - path = [ config.services.unbound.package ]; - serviceConfig = { - ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns"; - RuntimeDirectory = "router-leases-dns"; - Restart = "always"; - RestartSec = 5; - # It reads leases and talks to Unbound's socket; nothing else. - ProtectSystem = "strict"; - ProtectHome = true; - PrivateTmp = true; - PrivateNetwork = true; - PrivateDevices = true; - NoNewPrivileges = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictAddressFamilies = [ "AF_UNIX" ]; - LockPersonality = true; - MemoryDenyWriteExecute = true; - }; - }; + systemd.services.router-leases-dns = + lib.mkIf (config.router.enable && config.router.dns.dynamicLeases) + { + description = "Publish DHCP clients' names in DNS"; + wantedBy = [ "multi-user.target" ]; + after = [ + "unbound.service" + "kea-dhcp4-server.service" + ]; + path = [ config.services.unbound.package ]; + serviceConfig = { + ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns"; + RuntimeDirectory = "router-leases-dns"; + Restart = "always"; + RestartSec = 5; + # It reads leases and talks to Unbound's socket; nothing else. + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateNetwork = true; + PrivateDevices = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + }; + }; # Rebuilding on the device must work without internet (the uplink may be # what is being configured). What a changed configuration rebuilds is all diff --git a/pkgs/router-webui/certificate.go b/pkgs/router-webui/certificate.go new file mode 100644 index 0000000..babc954 --- /dev/null +++ b/pkgs/router-webui/certificate.go @@ -0,0 +1,262 @@ +package main + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "log" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" +) + +// CertInfo describes the certificate the web UI serves. +type CertInfo struct { + Subject, Issuer string + NotAfter time.Time + SelfSigned bool + Fingerprint string // SHA-256, colon separated +} + +func readCertInfo(path string) (CertInfo, bool) { + data, err := os.ReadFile(path) + if err != nil { + return CertInfo{}, false + } + block, _ := pem.Decode(data) + if block == nil { + return CertInfo{}, false + } + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return CertInfo{}, false + } + sum := sha256.Sum256(c.Raw) + hexSum := strings.ToUpper(hex.EncodeToString(sum[:])) + var pairs []string + for i := 0; i < len(hexSum); i += 2 { + pairs = append(pairs, hexSum[i:i+2]) + } + return CertInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), NotAfter: c.NotAfter, + SelfSigned: c.Subject.String() == c.Issuer.String(), Fingerprint: strings.Join(pairs, ":")}, true +} + +// certSource serves the certificate an ACME client keeps in dir while it is +// there and valid, and the self-signed one otherwise. Renewed files are picked +// up on the next connection. +type certSource struct { + fallback tls.Certificate + dir string + + mu sync.Mutex + loaded tls.Certificate + mod time.Time + ok bool +} + +func (c *certSource) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { + if c.dir == "" { + return &c.fallback, nil + } + chain, key := filepath.Join(c.dir, "fullchain.pem"), filepath.Join(c.dir, "key.pem") + a, errA := os.Stat(chain) + b, errB := os.Stat(key) + c.mu.Lock() + defer c.mu.Unlock() + if errA != nil || errB != nil { + c.ok = false + return &c.fallback, nil + } + mod := a.ModTime() + if b.ModTime().After(mod) { + mod = b.ModTime() + } + if !c.ok || !mod.Equal(c.mod) { + cert, err := tls.LoadX509KeyPair(chain, key) + c.mod = mod // do not retry a broken pair on every connection + c.ok = err == nil + if err != nil { + log.Printf("certificate in %s: %v", c.dir, err) + } else { + c.loaded = cert + } + } + if !c.ok { + return &c.fallback, nil + } + return &c.loaded, nil +} + +// ACMEForm is the certificate section of the settings (router.acme). +type ACMEForm struct { + Enabled bool + Provider string // a connection of router.dnsProviders + Domains string // one per line or comma separated + Email string + Staging bool + Agree bool +} + +func (s Settings) acme() ACMEForm { + router, _ := s["router"].(map[string]any) + c, _ := router["acme"].(map[string]any) + flag := func(k string, def bool) bool { + if v, ok := c[k].(bool); ok { + return v + } + return def + } + return ACMEForm{Enabled: flag("enable", false), Provider: str(c, "provider"), Domains: strings.Join(stringList(c, "domains"), "\n"), + Email: str(c, "email"), Staging: flag("staging", true), Agree: flag("acceptTerms", false)} +} + +func (s Settings) applyACME(f ACMEForm) error { + errs := FormErrors{} + f.Provider, f.Email = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Email) + var domains []any + for _, d := range splitList(f.Domains) { + if !ddnsHostRe.MatchString(d) || !strings.Contains(d, ".") { + errs["domains"] = d + " is not a host name" + break + } + domains = append(domains, strings.ToLower(d)) + } + if f.Provider != "" { + p, ok := s.dnsProviderMap()[f.Provider].(map[string]any) + switch { + case !ok: + errs["provider"] = "choose one of the DNS providers" + case !slices.Contains(acmeTypes, str(p, "type")): + errs["provider"] = "lego has no DNS provider " + str(p, "type") + "; it is for dynamic DNS" + case acmeTokens[str(p, "type")] != "" && str(p, "secretFile") == "": + errs["provider"] = "this account has no token (add it on the DNS providers page)" + case acmeTokens[str(p, "type")] == "" && str(p, "environmentFile") == "": + errs["provider"] = "this account has no certificate variables (add them on the DNS providers page)" + } + } + if f.Email != "" && !strings.Contains(f.Email, "@") { + errs["email"] = "an e-mail address" + } + if f.Enabled { + if f.Provider == "" { + errs["provider"] = "choose a DNS provider (add one on the DNS providers page)" + } + if len(domains) == 0 { + errs["domains"] = "name at least one host" + } + if f.Email == "" { + errs["email"] = "an address Let's Encrypt can write to about expiry" + } + if !f.Agree { + errs["agree"] = "accept the Let's Encrypt subscriber agreement" + } + } + if len(errs) > 0 { + return errs + } + c := obj(obj(s, "router"), "acme") + c["enable"], c["staging"], c["acceptTerms"] = f.Enabled, f.Staging, f.Agree + set := func(k, v string) { + if v == "" { + delete(c, k) + } else { + c[k] = v + } + } + set("provider", f.Provider) + set("email", f.Email) + if len(domains) > 0 { + c["domains"] = domains + } else { + delete(c, "domains") + } + return nil +} + +type certificateData struct { + Cert CertInfo + Has bool + Serving string // "acme" or "self-signed" + Form ACMEForm + Errors FormErrors + Providers []DNSProvider + Status string // the order's last result + Log string +} + +// servedCert is what the web UI hands out: the ACME certificate if it is there. +func (a *app) servedCert() (CertInfo, bool, string) { + if a.sys.ACMEDir != "" { + if c, ok := readCertInfo(filepath.Join(a.sys.ACMEDir, "fullchain.pem")); ok { + return c, true, "acme" + } + } + c, ok := readCertInfo(filepath.Join(a.sys.StateDir, "webui", "cert.pem")) + return c, ok, "self-signed" +} + +func (a *app) certificateData(s Settings, f ACMEForm, errs FormErrors) certificateData { + var providers []DNSProvider + for _, p := range a.dnsProviders(s) { + if p.ACME { + providers = append(providers, p) + } + } + d := certificateData{Form: f, Errors: errs, Providers: providers} + d.Cert, d.Has, d.Serving = a.servedCert() + if first := splitList(f.Domains); len(first) > 0 && s.acme().Enabled { + unit := "acme-order-renew-" + strings.ToLower(first[0]) + ".service" + if st := unitProperty(unit, "ActiveState"); st != "" { + d.Status = st + ", result " + unitProperty(unit, "Result") + d.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "15", "-u", unit) + } + } + return d +} + +func (a *app) certificatePage(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "certificate", p) + return + } + p.Data = a.certificateData(s, s.acme(), nil) + a.render(w, "certificate", p) +} + +func (a *app) certificateSave(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "certificate", p) + return + } + v := r.PostFormValue + f := ACMEForm{Enabled: v("enabled") == "on", Provider: v("provider"), Domains: v("domains"), Email: v("email"), + Staging: v("staging") == "on", Agree: v("agree") == "on"} + if err := s.applyACME(f); err != nil { + var fe FormErrors + errors.As(err, &fe) + p.Error = "Please correct the marked fields." + p.Data = a.certificateData(s, f, fe) + w.WriteHeader(http.StatusUnprocessableEntity) + a.render(w, "certificate", p) + return + } + if err := s.save(a.sys.settingsPath()); err != nil { + p.Error = "Saving: " + err.Error() + a.render(w, "certificate", p) + return + } + back(w, r, "Saved. Apply to order the certificate; this page shows how it went.") +} diff --git a/pkgs/router-webui/collect.go b/pkgs/router-webui/collect.go index 3e8e96f..9cbdf6c 100644 --- a/pkgs/router-webui/collect.go +++ b/pkgs/router-webui/collect.go @@ -297,7 +297,12 @@ var kernelDirRe = regexp.MustCompile(`-linux-([0-9][^/]*)/`) // (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]+"-") { + if m == nil || running == "" { + return "" + } + // The store name says 7.3, the kernel 7.3.0 (or 7.3.0-suffix). + base, _, _ := strings.Cut(running, "-") + if base == m[1] || base == m[1]+".0" { return "" } return m[1] diff --git a/pkgs/router-webui/ddns.go b/pkgs/router-webui/ddns.go index db24c81..1ebbe9a 100644 --- a/pkgs/router-webui/ddns.go +++ b/pkgs/router-webui/ddns.go @@ -4,23 +4,20 @@ import ( "errors" "net/http" "regexp" + "slices" "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 + Enabled bool + Provider string // a connection of router.dnsProviders + Zone string + Domains string // one per line or comma separated + IPv4 bool + IPv6 bool + SSL bool + Interval string } var ( @@ -40,30 +37,20 @@ func (s Settings) ddns() DDNSForm { 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"), + Enabled: flag("enable", false), Provider: str(d, "provider"), 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) { +// applyDDNS writes the form into the settings. +func (s Settings) applyDDNS(f DDNSForm) 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) + f.Provider, f.Zone, f.Interval = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval) var domains []any for _, d := range splitList(f.Domains) { if !ddnsHostRe.MatchString(d) { @@ -72,36 +59,36 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) { } 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.Provider != "" { + p, ok := s.dnsProviderMap()[f.Provider].(map[string]any) + switch { + case !ok: + errs["provider"] = "choose one of the DNS providers" + case !slices.Contains(ddnsTypes, str(p, "type")): + errs["provider"] = "dynamic DNS does not know " + str(p, "type") + "; it is for certificates" + case str(p, "secretFile") == "": + errs["provider"] = "this account has no password or token (add it on the DNS providers page)" + } } 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 f.Provider == "" { + errs["provider"] = "choose a DNS provider (add one on the DNS providers page)" + } 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 + 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 @@ -113,17 +100,14 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) { d[k] = v } } - set("server", f.Server) + set("provider", f.Provider) 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 + return nil } // DDNSStatus is what ddclient last did. @@ -141,9 +125,10 @@ func (s System) ddnsStatus() DDNSStatus { } type ddnsData struct { - Form DDNSForm - Errors FormErrors - Status DDNSStatus + Form DDNSForm + Errors FormErrors + Status DDNSStatus + Providers []DNSProvider } func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) { @@ -154,7 +139,7 @@ func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) { a.render(w, "ddns", p) return } - p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus()} + p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)} a.render(w, "ddns", p) } @@ -167,27 +152,17 @@ func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) { 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 { + f := DDNSForm{Enabled: v("enabled") == "on", Provider: v("provider"), Zone: v("zone"), Domains: v("domains"), + IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on", SSL: v("ssl") == "on", Interval: v("interval")} + if err := s.applyDDNS(f); 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()} + p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)} 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) diff --git a/pkgs/router-webui/ddns_test.go b/pkgs/router-webui/ddns_test.go index ca09508..fa6c591 100644 --- a/pkgs/router-webui/ddns_test.go +++ b/pkgs/router-webui/ddns_test.go @@ -4,103 +4,208 @@ 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 := mustParse(t) - if f := s.ddns(); f.Enabled || f.Protocol != "cloudflare" || f.Username != "token" || f.Interval != "5min" || !f.IPv4 || f.IPv6 { + 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, Protocol: "Cloudflare", Username: "token", Secret: " s3cret\n", Zone: "example.org", + 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"} - secret, err := s.applyDDNS(f, "/var/lib/router/ddns-secret") - if err != nil || secret != "s3cret" { - t.Fatalf("secret=%q err=%v", secret, err) + if err := s.applyDDNS(f); err != nil { + t.Fatal(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"`} { + 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) } } - 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" { + if got.Provider != "cf" || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" { 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) + 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.SecretSet || got.Zone != "example.org" { + 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, Protocol: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p", - Domains: "a.example.org", IPv4: true, SSL: true, Interval: "5min"} + 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!" }, - "protocol": func(f *DDNSForm) { f.Protocol = "no way" }, - "server": func(f *DDNSForm) { f.Server = "https://x.example" }, + "provider": func(f *DDNSForm) { f.Provider = "nothing" }, "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] == "" { + 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 := mustParse(t).applyDDNS(f, "/x"); err == nil { + if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); 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) + 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 TestDDNSPage(t *testing.T) { +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", "/ddns", nil, cookie).Body.String() - if !strings.Contains(body, `name="domains"`) || !strings.Contains(body, "Dynamic DNS") { + 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}, "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") { + 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("secret", "tok3n") - if rec := do(h, "POST", "/ddns", form, cookie); rec.Code != http.StatusSeeOther { + 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) } - data, err := os.ReadFile(sys.ddnsSecretPath()) + 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(sys.ddnsSecretPath()); fi.Mode().Perm() != 0o600 { + if fi, _ := os.Stat(path); 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") { + 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, `