Add dynamic DNS and show the running kernel on the overview
router.ddns keeps provider records on the uplink's addresses with ddclient, run by a timer and by a watcher that starts it when an address changes. The Dynamic DNS page edits it; the secret is a private file on the router. The overview shows the running kernel and says when the current system boots a different one. PLAN.md records why the A/B image approach was not pursued. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+1
-1
@@ -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
|
minute) or waits for a rollback timer. A source change rebuilds the systems
|
||||||
again on the host. So:
|
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
|
ones that install and rebuild on the device are the slow ones: `apply` and
|
||||||
`update` several minutes, `setup` 11 minutes (three installs).
|
`update` several minutes, `setup` 11 minutes (three installs).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.<n>.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.
|
2. ~~**ULA on the LAN**~~ **done**: `lans.<n>.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.<n>.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-<name>` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`).
|
3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard.<n>.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-<name>` 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.
|
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.**
|
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.
|
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.
|
- 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@<box>:` then `router-install --secrets /root/secrets /dev/<disk>`.
|
- Build: `nix build .#installer-example`, write `result/iso/*.iso` to a USB stick, boot the new box (UEFI), `scp -r secrets root@<box>:` then `router-install --secrets /root/secrets /dev/<disk>`.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
./firewall.nix
|
./firewall.nix
|
||||||
./dhcp.nix
|
./dhcp.nix
|
||||||
./dns.nix
|
./dns.nix
|
||||||
|
./ddns.nix
|
||||||
./rollback.nix
|
./rollback.nix
|
||||||
./power.nix
|
./power.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 <https://ddclient.net/protocols.html>.";
|
||||||
|
};
|
||||||
|
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 {
|
managementPorts = mkOption {
|
||||||
type = types.listOf types.port;
|
type = types.listOf types.port;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -67,6 +68,8 @@ type UplinkStatus struct {
|
|||||||
type SystemStatus struct {
|
type SystemStatus struct {
|
||||||
Uptime time.Duration
|
Uptime time.Duration
|
||||||
Load string
|
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
|
CPU float64 // percent; NaN until the second sample
|
||||||
MemUsed uint64
|
MemUsed uint64
|
||||||
MemTotal uint64
|
MemTotal uint64
|
||||||
@@ -253,6 +256,12 @@ func systemStatus() SystemStatus {
|
|||||||
s.Uptime = time.Duration(secs) * time.Second
|
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 data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
if f := strings.Fields(string(data)); len(f) >= 3 {
|
if f := strings.Fields(string(data)); len(f) >= 3 {
|
||||||
s.Load = strings.Join(f[:3], " ")
|
s.Load = strings.Join(f[:3], " ")
|
||||||
@@ -282,6 +291,18 @@ func systemStatus() SystemStatus {
|
|||||||
return s
|
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.
|
// Sensor is one temperature the machine reports.
|
||||||
type Sensor struct {
|
type Sensor struct {
|
||||||
Key string // stable name of its series
|
Key string // stable name of its series
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -243,7 +243,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
|||||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||||
pages := map[string]*template.Template{}
|
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).
|
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||||
if err != nil {
|
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/delete", a.auth(a.wireguardDelete))
|
||||||
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
|
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
|
||||||
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
|
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("GET /firewall", a.auth(a.firewallPage))
|
||||||
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
|
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
|
||||||
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
||||||
|
|||||||
@@ -52,3 +52,18 @@ func TestReadSensors(t *testing.T) {
|
|||||||
t.Errorf("no sensors: %+v (hottest should be NaN)", got)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ func (s System) leasesDNSDir() string {
|
|||||||
return "/run/router-leases-dns"
|
return "/run/router-leases-dns"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
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) 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) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
|
||||||
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-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) {
|
func command(name string, args ...string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<h2>System</h2>
|
<h2>System</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
|
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
|
||||||
|
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span>{{end}}</dd>{{end}}
|
||||||
<dt>Load</dt><dd>{{$sys.Load}}</dd>
|
<dt>Load</dt><dd>{{$sys.Load}}</dd>
|
||||||
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
|
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
|
||||||
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
|
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{with .Data}}
|
||||||
|
{{$e := .Errors}}{{$f := .Form}}
|
||||||
|
<p class="hint">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
|
||||||
|
<a href="https://ddclient.net/protocols.html">ddclient</a>; one provider account.</p>
|
||||||
|
<form method="post" action="/ddns">
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Provider</legend>
|
||||||
|
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Keep these names up to date</label>
|
||||||
|
<div>
|
||||||
|
<label>Provider (protocol) <input name="protocol" value="{{$f.Protocol}}" size="16" list="protocols" spellcheck="false"></label>
|
||||||
|
<datalist id="protocols"><option value="cloudflare"><option value="duckdns"><option value="dyndns2"><option value="namecheap"><option value="porkbun"><option value="hetzner"><option value="godaddy"><option value="digitalocean"></datalist>
|
||||||
|
<label>Server <input name="server" value="{{$f.Server}}" size="26" spellcheck="false" placeholder="only for dyndns2"></label>
|
||||||
|
<label>Zone <input name="zone" value="{{$f.Zone}}" size="22" spellcheck="false" placeholder="example.org (Cloudflare)"></label>
|
||||||
|
</div>
|
||||||
|
{{range $k := list "protocol" "server" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
|
||||||
|
<div>
|
||||||
|
<label>Login <input name="username" value="{{$f.Username}}" size="18" spellcheck="false" autocomplete="off"></label>
|
||||||
|
<label>Password or API token <input type="password" name="secret" size="30" autocomplete="new-password"
|
||||||
|
placeholder="{{if $f.SecretSet}}stored; leave empty to keep{{else}}required{{end}}"></label>
|
||||||
|
</div>
|
||||||
|
{{with index $e "secret"}}<p class="field-error">{{.}}</p>{{end}}
|
||||||
|
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). The secret is kept on the router
|
||||||
|
in a private file, not in the settings.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Names</legend>
|
||||||
|
<label>Host names, one per line <textarea name="domains" rows="6" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
|
||||||
|
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
|
||||||
|
<div>
|
||||||
|
<label class="inline"><input type="checkbox" name="ipv4" {{if $f.IPv4}}checked{{end}}> IPv4 (A records)</label>
|
||||||
|
<label class="inline"><input type="checkbox" name="ipv6" {{if $f.IPv6}}checked{{end}}> IPv6 (AAAA records)</label>
|
||||||
|
<label class="inline"><input type="checkbox" name="ssl" {{if $f.SSL}}checked{{end}}> HTTPS to the provider</label>
|
||||||
|
<label>Check every <input name="interval" value="{{$f.Interval}}" size="6"></label>
|
||||||
|
</div>
|
||||||
|
{{range $k := list "ipv4" "interval"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
|
||||||
|
</fieldset>
|
||||||
|
<button>Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Last updates</h2>
|
||||||
|
{{if .Status.Log}}<p class="muted">ddclient: {{or .Status.State "unknown"}}{{if .Status.Last}}, last run {{.Status.Last}}{{end}}</p>
|
||||||
|
<pre class="log">{{.Status.Log}}</pre>
|
||||||
|
{{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<a href="/dns">DNS</a>
|
<a href="/dns">DNS</a>
|
||||||
<a href="/wireguard">WireGuard</a>
|
<a href="/wireguard">WireGuard</a>
|
||||||
<a href="/firewall">Firewall</a>
|
<a href="/firewall">Firewall</a>
|
||||||
|
<a href="/ddns">Dynamic DNS</a>
|
||||||
<a href="/update">Update</a>
|
<a href="/update">Update</a>
|
||||||
<a href="/advanced">Advanced</a>
|
<a href="/advanced">Advanced</a>
|
||||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
# and test_script() work; boot once, retry steps
|
# and test_script() work; boot once, retry steps
|
||||||
# router-test --list
|
# router-test --list
|
||||||
# Logs: ${TMPDIR:-/tmp}/router-test-logs/NAME.log
|
# 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:-.}
|
flake=${FLAKE:-.}
|
||||||
logs=${TMPDIR:-/tmp}/router-test-logs
|
logs=${TMPDIR:-/tmp}/router-test-logs
|
||||||
mkdir -p "$logs"
|
mkdir -p "$logs"
|
||||||
|
|||||||
@@ -310,6 +310,50 @@ let
|
|||||||
};
|
};
|
||||||
expect = [ "ports are only valid with protocol tcp or udp" ];
|
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 = {
|
ruleSourcePortsWithoutProtocol = {
|
||||||
router = {
|
router = {
|
||||||
lans = {
|
lans = {
|
||||||
|
|||||||
+113
@@ -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)
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
onestick = import ./onestick.nix { inherit self pkgs; };
|
onestick = import ./onestick.nix { inherit self pkgs; };
|
||||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||||
dns = import ./dns.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; };
|
installer = import ./installer.nix { inherit self pkgs; };
|
||||||
apply = import ./apply.nix { inherit self pkgs; };
|
apply = import ./apply.nix { inherit self pkgs; };
|
||||||
webui = import ./webui.nix { inherit self pkgs; };
|
webui = import ./webui.nix { inherit self pkgs; };
|
||||||
|
|||||||
@@ -144,6 +144,16 @@ pkgs.testers.runNixOSTest {
|
|||||||
saved = router.succeed(f"cat {cfg}")
|
saved = router.succeed(f"cat {cfg}")
|
||||||
assert '"dnat": true' in saved and '"from": "wan"' in saved, saved
|
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"):
|
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")
|
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
|
||||||
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
||||||
|
|||||||
Reference in New Issue
Block a user