diff --git a/DEVELOPING.md b/DEVELOPING.md index 4fae098..b769ad0 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -45,7 +45,7 @@ runs `router-apply` on the router (building a NixOS system there takes about a minute) or waits for a rollback timer. A source change rebuilds the systems again on the host. So: -Measured (one at a time): `webui` 45 s, `basic` 107 s, `onestick` 77 s; the +Measured (one at a time): `webui` 45 s, `ddns` 43 s, `basic` 107 s, `onestick` 77 s; the ones that install and rebuild on the device are the slow ones: `apply` and `update` several minutes, `setup` 11 minutes (three installs). diff --git a/PLAN.md b/PLAN.md index 19e3ba3..fe99088 100644 --- a/PLAN.md +++ b/PLAN.md @@ -153,7 +153,7 @@ Open (in rough order): 1. ~~**IPv6 over the PPP session**~~ **done**: `wan.ipv6.transport = "ppp"` (default `native`) enables IPv6CP in pppd, RA + DHCPv6-PD via networkd on the PPP device, PD to the LAN, DHCPv6 replies accepted on the PPP device and the IPv6 MSS clamped to MTU-60 (1432). Allowed on a shared link (single NIC). Tested in `onestick` (ISP with radvd and a minimal DHCPv6-PD responder, since Kea cannot serve point-to-point links); mutation-tested (MSS clamp, DHCPv6 input rule). `ipv6cp-use-ipaddr` is set as on antigua. 2. ~~**ULA on the LAN**~~ **done**: `lans..ipv6.address` (a ULA /64, e.g. `fd2c:cdef:cde9::1/64`) is assigned, announced via SLAAC (explicit `[IPv6Prefix]`, because networkd announces only the delegated prefix when PD is on) and advertised as RDNSS; Unbound listens on it. Build-time checks: valid IPv6 CIDR, /64, fc00::/7. Tested in `onestick` (client SLAAC ULA, ping and DNS over the ULA, RA captured on the wire) and assertion cases; mutation-tested. 3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard..sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`). -4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS stays in the host config (not part of the module). +4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it. 5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder. 6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (`firewall.rules` with `from = "wan"`); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.** 7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests. @@ -167,6 +167,15 @@ Open (in rough order): - Status: `nix build .#installer-example` produces a 1.6 GB ISO. **Not yet tested**: booting that ISO on real firmware (the VM test exercises `router-install`, not the live ISO), the NIC name in the example, and installing over an existing system. - Build: `nix build .#installer-example`, write `result/iso/*.iso` to a USB stick, boot the new box (UEFI), `scp -r secrets root@:` then `router-install --secrets /root/secrets /dev/`. +## A/B image product: considered, not pursued + +Considered 2026-10-30: moving the product to the A/B dm-verity appliance image (`modules/image`) with a +runtime renderer for `settings.json`. Dropped in favour of the current approach (installed NixOS, on-device +builds with `router-apply`, signed Nix-cache updates). Reasons: a read-only store cannot build a new system, so +the choice was a Go reimplementation of ~2300 lines of Nix module logic, or Nix with a writable overlay on the +box, which makes every post-update boot wait for a rebuild and leaves the settings-derived system outside +verity. The image module and its test (`checks.image`) stay as they are for anyone who wants a fixed image. + ## Open items - **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system. diff --git a/modules/router/ddns.nix b/modules/router/ddns.nix new file mode 100644 index 0000000..4f69c8f --- /dev/null +++ b/modules/router/ddns.nix @@ -0,0 +1,85 @@ +# Dynamic DNS: ddclient keeps the provider's records on the uplink's addresses. +# It runs from a timer; a watcher starts it as soon as an address of the uplink +# changes, so a new PPPoE session or lease is published within seconds. +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.router; + ddns = cfg.ddns; + + # Where the addresses are: IPv4 on the PPP device, IPv6 on the physical uplink + # (or the PPP device when IPv6 runs over it). + dev4 = cfg.wan.ipv4.interface; + dev6 = if cfg.wan.ipv6.enable && cfg.wan.ipv6.transport == "ppp" then dev4 else cfg.wan.interface; + devices = lib.unique (lib.optional ddns.ipv4 dev4 ++ lib.optional ddns.ipv6 dev6); + + watch = pkgs.writeShellApplication { + name = "router-ddns-watch"; + runtimeInputs = [ + pkgs.iproute2 + pkgs.gnugrep + pkgs.systemd + ]; + text = '' + # `ip monitor` also reports the lines of devices that do not exist yet. + ip -o monitor address \ + | grep --line-buffered ${lib.concatMapStringsSep " " (d: "-e ' ${d} '") devices} \ + | while read -r _; do + sleep 5 # an address change comes as several events + systemctl start --no-block ddclient.service + done + ''; + }; +in +{ + config = lib.mkIf (cfg.enable && ddns.enable) { + assertions = [ + { + assertion = ddns.domains != [ ]; + message = "router.ddns: no domains to update."; + } + { + assertion = ddns.ipv4 || ddns.ipv6; + 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)."; + } + ]; + + services.ddclient = { + enable = true; + inherit (ddns) + protocol + username + passwordFile + domains + ssl + interval + ; + server = lib.optionalString (ddns.server != null) ddns.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}"; + # ddclient only sends a changed address; its default of 30 s between + # updates would hold back a new one that follows closely on another + # (a PPP session that drops and comes back). + extraConfig = "min-interval=1s"; + }; + + systemd.services.router-ddns-watch = { + description = "Update dynamic DNS when the uplink's address changes"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = lib.getExe watch; + Restart = "always"; + RestartSec = 5; + }; + }; + }; +} diff --git a/modules/router/default.nix b/modules/router/default.nix index 4073bc3..2ac4769 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -8,6 +8,7 @@ ./firewall.nix ./dhcp.nix ./dns.nix + ./ddns.nix ./rollback.nix ./power.nix ]; diff --git a/modules/router/options.nix b/modules/router/options.nix index d607470..4aeaa77 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -498,6 +498,73 @@ in ); }; + ddns = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Keep DNS records at a provider pointing at the router's uplink + addresses (ddclient). It checks every `interval` and as soon as an + uplink address changes. + ''; + }; + protocol = mkOption { + type = types.str; + default = "cloudflare"; + example = "dyndns2"; + description = "ddclient protocol of the provider, see ."; + }; + 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`)."; + }; + 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."; + }; + zone = mkOption { + type = types.nullOr types.str; + default = null; + example = "example.org"; + description = "The zone the names are in, for providers that need it (Cloudflare)."; + }; + domains = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "home.example.org" ]; + description = "Names to keep up to date."; + }; + ipv4 = mkOption { + type = types.bool; + default = true; + description = "Update A records with the IPv4 address of the uplink."; + }; + ipv6 = mkOption { + type = types.bool; + default = false; + description = "Update AAAA records with the IPv6 address of the uplink."; + }; + ssl = mkOption { + type = types.bool; + default = true; + description = "Talk to the provider over HTTPS."; + }; + interval = mkOption { + type = types.str; + default = "5min"; + description = "How often to check, besides when an address changes (systemd time span)."; + }; + }; + managementPorts = mkOption { type = types.listOf types.port; default = [ ]; diff --git a/pkgs/router-webui/collect.go b/pkgs/router-webui/collect.go index 8cb2fe5..3e8e96f 100644 --- a/pkgs/router-webui/collect.go +++ b/pkgs/router-webui/collect.go @@ -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 diff --git a/pkgs/router-webui/ddns.go b/pkgs/router-webui/ddns.go new file mode 100644 index 0000000..db24c81 --- /dev/null +++ b/pkgs/router-webui/ddns.go @@ -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.") +} diff --git a/pkgs/router-webui/ddns_test.go b/pkgs/router-webui/ddns_test.go new file mode 100644 index 0000000..ca09508 --- /dev/null +++ b/pkgs/router-webui/ddns_test.go @@ -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) + } +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 1f03194..4c61cd5 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -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)) diff --git a/pkgs/router-webui/sensors_test.go b/pkgs/router-webui/sensors_test.go index 73d073a..6eb29cd 100644 --- a/pkgs/router-webui/sensors_test.go +++ b/pkgs/router-webui/sensors_test.go @@ -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") + } +} diff --git a/pkgs/router-webui/system.go b/pkgs/router-webui/system.go index bd49736..b9eebb3 100644 --- a/pkgs/router-webui/system.go +++ b/pkgs/router-webui/system.go @@ -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) diff --git a/pkgs/router-webui/templates/dashboard.html b/pkgs/router-webui/templates/dashboard.html index 7f27083..97eca3a 100644 --- a/pkgs/router-webui/templates/dashboard.html +++ b/pkgs/router-webui/templates/dashboard.html @@ -33,6 +33,7 @@

System

Uptime
{{age $sys.Uptime}}
+ {{if $sys.Kernel}}
Kernel
{{$sys.Kernel}}{{if $sys.KernelNext}} reboot to run {{$sys.KernelNext}}{{end}}
{{end}}
Load
{{$sys.Load}}
CPU
{{value $sys.CPU "%"}}
{{if $sys.MemTotal}}
Memory
{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}
{{end}} diff --git a/pkgs/router-webui/templates/ddns.html b/pkgs/router-webui/templates/ddns.html new file mode 100644 index 0000000..e24bb9d --- /dev/null +++ b/pkgs/router-webui/templates/ddns.html @@ -0,0 +1,50 @@ +{{define "content"}} +{{with .Data}} +{{$e := .Errors}}{{$f := .Form}} +

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 + ddclient; one provider account.

+
+ +
+ Provider + +
+ + + + +
+ {{range $k := list "protocol" "server" "zone"}}{{with index $e $k}}

{{$k}}: {{.}}

{{end}}{{end}} +
+ + +
+ {{with index $e "secret"}}

{{.}}

{{end}} +

Login is token for a Cloudflare API token (permission Zone → DNS → Edit). The secret is kept on the router + in a private file, not in the settings.

+
+
+ Names + + {{with index $e "domains"}}

{{.}}

{{end}} +
+ + + + +
+ {{range $k := list "ipv4" "interval"}}{{with index $e $k}}

{{$k}}: {{.}}

{{end}}{{end}} +
+ +
+ +
+

Last updates

+ {{if .Status.Log}}

ddclient: {{or .Status.State "unknown"}}{{if .Status.Last}}, last run {{.Status.Last}}{{end}}

+
{{.Status.Log}}
+ {{else}}

Nothing yet. Once applied, the log of ddclient shows up here.

{{end}} +
+{{end}} +{{end}} diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html index ac10621..43b47da 100644 --- a/pkgs/router-webui/templates/layout.html +++ b/pkgs/router-webui/templates/layout.html @@ -18,6 +18,7 @@ DNS WireGuard Firewall + Dynamic DNS Update Advanced
diff --git a/scripts/test.sh b/scripts/test.sh index e83e984..ae35d18 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,7 +6,7 @@ # and test_script() work; boot once, retry steps # router-test --list # Logs: ${TMPDIR:-/tmp}/router-test-logs/NAME.log -vm_tests="webui basic pppoe vlans onestick rollback dns installer apply setup update image wireguard" +vm_tests="webui basic pppoe vlans onestick rollback dns ddns installer apply setup update image wireguard" flake=${FLAKE:-.} logs=${TMPDIR:-/tmp}/router-test-logs mkdir -p "$logs" diff --git a/tests/assertions.nix b/tests/assertions.nix index dedde3a..6dbbc45 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -310,6 +310,50 @@ let }; expect = [ "ports are only valid with protocol tcp or udp" ]; }; + ddnsValid = { + router = { + lans.main = lan { }; + ddns = { + enable = true; + domains = [ "home.example.org" ]; + passwordFile = "/var/lib/router/ddns-secret"; + }; + }; + expect = [ ]; + }; + ddnsSecretInTheStore = { + router = { + lans.main = lan { }; + ddns = { + enable = true; + domains = [ "home.example.org" ]; + passwordFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret"; + }; + }; + expect = [ "outside the Nix store" ]; + }; + ddnsWithoutNames = { + router = { + lans.main = lan { }; + ddns = { + enable = true; + passwordFile = "/var/lib/router/ddns-secret"; + }; + }; + expect = [ "no domains to update" ]; + }; + ddnsWithoutAnyFamily = { + router = { + lans.main = lan { }; + ddns = { + enable = true; + domains = [ "home.example.org" ]; + passwordFile = "/var/lib/router/ddns-secret"; + ipv4 = false; + }; + }; + expect = [ "neither IPv4 nor IPv6" ]; + }; ruleSourcePortsWithoutProtocol = { router = { lans = { diff --git a/tests/ddns.nix b/tests/ddns.nix new file mode 100644 index 0000000..ba85638 --- /dev/null +++ b/tests/ddns.nix @@ -0,0 +1,113 @@ +# Dynamic DNS: the router tells its provider about the uplink's address, at +# boot and as soon as the address changes (the timer is set to an hour, so +# only the watcher can explain the second update). +# +# upstream (ISP: DHCP, a dyndns2 update server) -- router +{ self, pkgs }: +let + common = import ./common.nix; +in +pkgs.testers.runNixOSTest { + name = "router-ddns"; + + defaults.imports = [ common.tools ]; + interactive.defaults = common.interactiveDefaults; + + nodes = { + upstream = + { lib, ... }: + { + virtualisation.vlans = [ 1 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.firewall.enable = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + systemd.network.networks."10-wan" = { + matchConfig.Name = "eth1"; + address = [ "203.0.113.1/24" ]; + }; + services.dnsmasq = { + enable = true; + settings = { + interface = "eth1"; + bind-interfaces = true; + dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h"; + dhcp-option = [ "option:router,203.0.113.1" ]; + }; + }; + # What a dyndns2 provider answers; the access log is the test's record. + services.nginx = { + enable = true; + virtualHosts.default = { + default = true; + locations."/nic/update".return = ''200 "good $arg_myip"''; + }; + }; + }; + + router = + { lib, ... }: + { + imports = [ self.nixosModules.router ]; + virtualisation.vlans = [ + 1 + 2 + ]; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ]; + + router = { + enable = true; + wan.interface = "eth1"; + lans.main = { + interface = "eth2"; + ipv4.address = "192.168.10.1"; + dhcp.pool = { + start = "192.168.10.100"; + end = "192.168.10.199"; + }; + }; + ddns = { + enable = true; + protocol = "dyndns2"; + server = "203.0.113.1"; + ssl = false; + username = "router"; + passwordFile = "/run/ddns-secret"; + domains = [ + "home.example.test" + "vpn.example.test" + ]; + interval = "1h"; + }; + }; + + # The secret is a file on the router, as the web UI leaves it. + systemd.tmpfiles.rules = [ "f+ /run/ddns-secret 0600 root root - hunter2" ]; + }; + }; + + testScript = '' + start_all() + upstream.wait_for_unit("nginx.service") + router.wait_for_unit("network-online.target") + router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100") + router.wait_for_unit("router-ddns-watch.service") + + def updated(addr): + return f"grep -q 'myip={addr}' /var/log/nginx/access.log" + + with subtest("the first address is published, for every name"): + upstream.wait_until_succeeds(updated("203.0.113.100"), timeout=90) + out = upstream.succeed("cat /var/log/nginx/access.log") + for name in ("home.example.test", "vpn.example.test"): + assert f"hostname={name}" in out or name in out, out + # The login arrives as basic auth, not in the URL, and the secret stays off the command line. + router.fail("grep -r hunter2 /etc/ddclient* /nix/store/*ddclient.conf 2>/dev/null") + + with subtest("a new address is published at once, not at the next timer"): + router.succeed("ip addr add 203.0.113.101/24 dev eth1") + router.succeed("ip addr del 203.0.113.100/24 dev eth1") + upstream.wait_until_succeeds(updated("203.0.113.101"), timeout=60) + ''; +} diff --git a/tests/default.nix b/tests/default.nix index 92c67b1..87cdde4 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -9,6 +9,7 @@ onestick = import ./onestick.nix { inherit self pkgs; }; rollback = import ./rollback.nix { inherit self pkgs; }; dns = import ./dns.nix { inherit self pkgs; }; + ddns = import ./ddns.nix { inherit self pkgs; }; installer = import ./installer.nix { inherit self pkgs; }; apply = import ./apply.nix { inherit self pkgs; }; webui = import ./webui.nix { inherit self pkgs; }; diff --git a/tests/webui.nix b/tests/webui.nix index d9148a8..b3e7ca5 100644 --- a/tests/webui.nix +++ b/tests/webui.nix @@ -144,6 +144,16 @@ pkgs.testers.runNixOSTest { saved = router.succeed(f"cat {cfg}") assert '"dnat": true' in saved and '"from": "wan"' in saved, saved + with subtest("dynamic DNS is saved with its secret kept out of the settings"): + dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org", + "domains": "home.example.org", "ipv4": "on", "ssl": "on", "interval": "5min"} + assert post("/ddns", {"csrf": csrf("/ddns"), **dd}) == "422" + assert post("/ddns", {"csrf": csrf("/ddns"), "secret": "tok3n-abc", **dd}) == "303" + saved = router.succeed(f"cat {cfg}") + assert '"home.example.org"' in saved and "tok3n-abc" not in saved, saved + assert router.succeed("stat -c '%a' /var/lib/router/ddns-secret").strip() == "600" + assert "tok3n-abc" not in curl(f"{ui}/ddns") + with subtest("the web UI hands out a backup of the router's state"): curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup") files = client.succeed("tar -tzf /tmp/backup.tar.gz")