Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect

- router.dnsProviders: named accounts at DNS providers, used by dynamic
  DNS and certificates (ddclient and lego provider lists in
  pkgs/router-webui/providers.json). Schema 2 migrates router.ddns.
- router.acme: a Let's Encrypt certificate for the web UI via
  security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the
  web UI; Certificate page.
- Web UI: password change (web UI and root), reboot button, HTTP to HTTPS
  redirect (router.webui.httpPort), grouped menu.
- Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel
  for the reboot hint.
- SPECS.md: wanted later (REST API, GraphQL, MCP).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-30 19:27:49 +01:00
co-authored by Claude Sonnet 5.5
parent a32bf42777
commit caf2c6b26d
33 changed files with 1985 additions and 224 deletions
+6
View File
@@ -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/<first name>/{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.
+16
View File
@@ -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.
+19
View File
@@ -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
+73
View File
@@ -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";
};
};
};
}
+25 -11
View File
@@ -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}";
+1
View File
@@ -9,6 +9,7 @@
./dhcp.nix
./dns.nix
./ddns.nix
./acme.nix
./rollback.nix
./power.nix
];
+108 -20
View File
@@ -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
(<https://ddclient.net/protocols.html>); for certificates the
provider code of lego (<https://go-acme.github.io/lego/dns>).
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 <https://go-acme.github.io/lego/dns>. 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 <https://ddclient.net/protocols.html>.";
};
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;
+9 -2
View File
@@ -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";
+62 -30
View File
@@ -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
+262
View File
@@ -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.")
}
+6 -1
View File
@@ -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]
+38 -63
View File
@@ -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)
+146 -41
View File
@@ -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, `<option value="cf"`) {
t.Fatalf("the account is not offered:\n%s", body)
}
token = regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
dd := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "zone": {"example.org"}, "domains": {"home.example.org"},
"ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
if rec := do(h, "POST", "/ddns", dd, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if f := s.ddns(); !f.Enabled || f.Provider != "cf" || f.Domains != "home.example.org" {
t.Fatalf("not saved: %+v", f)
}
// ... and then it cannot be removed.
form = url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.remove": {"on"}}
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "used by Dynamic DNS") {
t.Fatalf("remove: %d\n%s", rec.Code, rec.Body)
}
}
func TestEnvironmentProviders(t *testing.T) {
s := mustParse(t)
dir := t.TempDir()
rows := []DNSProvider{{Name: "aws", Type: "route53", Env: "AWS_ACCESS_KEY_ID=a\r\nAWS_SECRET_ACCESS_KEY=b\n", New: true}}
writes, _, err := s.applyDNSProviders(rows, dir)
if err != nil || len(writes) != 1 || writes[0].Path != filepath.Join(dir, "aws.env") || writes[0].Secret != "AWS_ACCESS_KEY_ID=a\nAWS_SECRET_ACCESS_KEY=b\n" {
t.Fatalf("writes=%+v err=%v", writes, err)
}
out := string(mustMarshal(t, s))
if !strings.Contains(out, `"environmentFile"`) || strings.Contains(out, "secretFile") || strings.Contains(out, "AWS_SECRET") {
t.Errorf("settings:\n%s", out)
}
// Certificates accept it; dynamic DNS does not know route53.
good := ACMEForm{Enabled: true, Provider: "aws", Domains: "r.example.org", Email: "a@b.c", Agree: true}
if err := s.applyACME(good); err != nil {
t.Errorf("certificate through route53: %v", err)
}
if err := s.applyDDNS(DDNSForm{Enabled: true, Provider: "aws", Domains: "a.example.org", IPv4: true}); err == nil || err.(FormErrors)["provider"] == "" {
t.Errorf("dynamic DNS through route53 accepted: %v", err)
}
// A single-token provider needs the token, not variables.
cf := withProvider(mustParse(t), "cf", "cloudflare")
delete(cf.dnsProviderMap()["cf"].(map[string]any), "secretFile")
if err := cf.applyACME(ACMEForm{Enabled: true, Provider: "cf", Domains: "r.example.org", Email: "a@b.c", Agree: true}); err == nil {
t.Error("cloudflare without a token accepted")
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "route53", Env: "not variables", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].env"] == "" {
t.Errorf("bad variables accepted: %v", err)
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "nonesuch", Secret: "s", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].type"] == "" {
t.Errorf("an unknown provider accepted: %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
)
// providers.json is shared with the Nix modules: the ddclient protocols
// (ddns), the lego DNS providers (acme) and those of them with a single token
// (acmeToken), whose secret is that token; the others take lego's variables.
//
//go:embed providers.json
var providersJSON []byte
var acmeTypes, ddnsTypes []string
var acmeTokens map[string]string
func init() {
var p struct {
DDNS []string `json:"ddns"`
ACME []string `json:"acme"`
ACMEToken map[string]string `json:"acmeToken"`
}
if err := json.Unmarshal(providersJSON, &p); err != nil {
panic(err)
}
ddnsTypes, acmeTypes, acmeTokens = p.DDNS, p.ACME, p.ACMEToken
}
// TypeOption is an entry of the provider drop-down.
type TypeOption struct {
Name, Label string
Selected bool
}
// providerTypeOptions lists every supported provider, with what each can do;
// a type the settings already have but the list lacks stays selectable.
func providerTypeOptions(current string) []TypeOption {
names := append([]string{}, ddnsTypes...)
for _, t := range acmeTypes {
if !slices.Contains(names, t) {
names = append(names, t)
}
}
if current != "" && !slices.Contains(names, current) {
names = append(names, current)
}
sort.Strings(names)
var out []TypeOption
for _, n := range names {
var uses []string
if slices.Contains(ddnsTypes, n) {
uses = append(uses, "dynamic DNS")
}
if slices.Contains(acmeTypes, n) {
uses = append(uses, "certificates")
}
label := n
if len(uses) > 0 {
label += " (" + strings.Join(uses, ", ") + ")"
}
out = append(out, TypeOption{Name: n, Label: label, Selected: n == current})
}
return out
}
type providerRow struct {
DNSProvider
Options []TypeOption
}
func providerRows(rows []DNSProvider) []providerRow {
out := make([]providerRow, len(rows))
for i, r := range rows {
out[i] = providerRow{r, providerTypeOptions(r.Type)}
}
return out
}
var envLineRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=.*$`)
var providerNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
// DNSProvider is a connection to a DNS provider (router.dnsProviders).
type DNSProvider struct {
Name string
Type string
Username string
Server string
Secret string // form only, write-only: empty keeps the stored one
SecretSet bool
Env string // form only, write-only: lego's VARIABLE=value lines
EnvSet bool
New bool // form only: the name may be chosen
Remove bool
ACME bool // can order certificates
}
func (s Settings) dnsProviderMap() map[string]any {
router, _ := s["router"].(map[string]any)
m, _ := router["dnsProviders"].(map[string]any)
return m
}
func (a *app) providerFileSet(s Settings, name, key string) bool {
p, _ := s.dnsProviderMap()[name].(map[string]any)
f := str(p, key)
if f == "" {
return false
}
fi, err := os.Stat(f)
return err == nil && fi.Size() > 0
}
func (a *app) dnsProviders(s Settings) []DNSProvider {
var out []DNSProvider
for name, v := range s.dnsProviderMap() {
p, _ := v.(map[string]any)
if p == nil {
continue
}
out = append(out, DNSProvider{Name: name, Type: str(p, "type"), Username: str(p, "username"), Server: str(p, "server"),
SecretSet: a.providerFileSet(s, name, "secretFile"), EnvSet: a.providerFileSet(s, name, "environmentFile"), ACME: slices.Contains(acmeTypes, str(p, "type"))})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// providerUsers lists what refers to the connection.
func (s Settings) providerUsers(name string) []string {
router, _ := s["router"].(map[string]any)
var users []string
if d, _ := router["ddns"].(map[string]any); str(d, "provider") == name {
users = append(users, "Dynamic DNS")
}
if c, _ := router["acme"].(map[string]any); str(c, "provider") == name {
users = append(users, "the certificate")
}
return users
}
// knownType is a provider of ddclient or lego.
func knownType(t string) bool { return slices.Contains(ddnsTypes, t) || slices.Contains(acmeTypes, t) }
// secretWrite is a secret to store; secretDelete a file to remove once the
// settings are saved.
type secretWrite struct{ Path, Secret string }
// applyDNSProviders replaces the connections by the rows. New secrets go to
// secretDir/NAME unless the connection has a file already.
func (s Settings) applyDNSProviders(rows []DNSProvider, secretDir string) (writes []secretWrite, deletes []string, err error) {
errs := FormErrors{}
old := s.dnsProviderMap()
next := map[string]any{}
for name, v := range old { // connections not in the form stay
next[name] = v
}
seen := map[string]bool{}
for i, r := range rows {
field := func(n string) string { return fmt.Sprintf("prov[%d].%s", i, n) }
r.Name, r.Type = strings.ToLower(strings.TrimSpace(r.Name)), strings.ToLower(strings.TrimSpace(r.Type))
r.Username, r.Server, r.Secret = strings.TrimSpace(r.Username), strings.TrimSpace(r.Server), strings.TrimSpace(r.Secret)
r.Env = strings.TrimSpace(strings.ReplaceAll(r.Env, "\r\n", "\n"))
if r.Name == "" && r.Secret == "" && r.Env == "" && r.Server == "" {
continue // the add row, left as it was offered (type and login are pre-filled)
}
oldRow, exists := old[r.Name].(map[string]any)
if r.New && exists {
errs[field("name")] = "there is a connection with this name"
}
if !providerNameRe.MatchString(r.Name) {
errs[field("name")] = "lower case letters, digits and dashes"
}
if seen[r.Name] {
errs[field("name")] = "the same name twice"
}
seen[r.Name] = true
if r.Remove {
if users := s.providerUsers(r.Name); len(users) > 0 {
errs[field("name")] = "used by " + strings.Join(users, " and ")
continue
}
for _, k := range []string{"secretFile", "environmentFile"} {
if f := str(oldRow, k); f != "" {
deletes = append(deletes, f)
}
}
delete(next, r.Name)
continue
}
if !protocolRe.MatchString(r.Type) || !(knownType(r.Type) || r.Type == str(oldRow, "type")) {
errs[field("type")] = "choose a provider from the list"
}
for _, l := range strings.Split(r.Env, "\n") {
if l = strings.TrimSpace(l); l != "" && !envLineRe.MatchString(l) {
errs[field("env")] = l + " is not VARIABLE=value"
break
}
}
if r.Server != "" && !serverRe.MatchString(r.Server) {
errs[field("server")] = "a host name, without http://"
}
if strings.ContainsAny(r.Username, "\n\r") {
errs[field("username")] = "one line"
}
secretFile := str(oldRow, "secretFile")
if secretFile == "" {
secretFile = filepath.Join(secretDir, r.Name)
}
envFile := str(oldRow, "environmentFile")
if envFile == "" {
envFile = filepath.Join(secretDir, r.Name+".env")
}
hasSecret := r.Secret != "" || str(oldRow, "secretFile") != ""
hasEnv := r.Env != "" || str(oldRow, "environmentFile") != ""
if !hasSecret && !hasEnv {
errs[field("secret")] = "the provider's password or API token, or its variables for certificates"
}
row := map[string]any{"type": r.Type}
if hasSecret {
row["secretFile"] = secretFile
}
if hasEnv {
row["environmentFile"] = envFile
}
if r.Username != "" {
row["username"] = r.Username
}
if r.Server != "" {
row["server"] = r.Server
}
next[r.Name] = row
if r.Secret != "" {
writes = append(writes, secretWrite{secretFile, r.Secret})
}
if r.Env != "" {
writes = append(writes, secretWrite{envFile, r.Env + "\n"})
}
}
if len(errs) > 0 {
return nil, nil, errs
}
if len(next) == 0 {
delete(obj(s, "router"), "dnsProviders")
} else {
obj(s, "router")["dnsProviders"] = next
}
return writes, deletes, nil
}
type providersData struct {
Rows []providerRow
Errors FormErrors
}
func (a *app) providersPage(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "dnsproviders", p)
return
}
rows := append(a.dnsProviders(s), DNSProvider{New: true, Type: "cloudflare", Username: "token"})
p.Data = providersData{Rows: providerRows(rows)}
a.render(w, "dnsproviders", p)
}
func (a *app) providersSave(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "dnsproviders", p)
return
}
n := 0
fmt.Sscanf(r.PostFormValue("providers"), "%d", &n)
n = min(max(n, 0), 50)
var rows []DNSProvider
for i := 0; i < n; i++ {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("prov.%d.%s", i, k)) }
rows = append(rows, DNSProvider{Name: v("name"), Type: v("type"), Username: v("username"), Server: v("server"),
Secret: v("secret"), Env: v("env"), New: v("new") == "1", Remove: v("remove") == "on"})
}
writes, deletes, err := s.applyDNSProviders(rows, filepath.Join(a.sys.StateDir, "dns-secrets"))
if err != nil {
var fe FormErrors
errors.As(err, &fe)
for i := range rows {
rows[i].Secret, rows[i].Env = "", ""
_, rows[i].SecretSet = s.dnsProviderMap()[strings.ToLower(strings.TrimSpace(rows[i].Name))].(map[string]any)
}
p.Error = "Please correct the marked fields."
p.Data = providersData{Rows: providerRows(rows), Errors: fe}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "dnsproviders", p)
return
}
for _, wr := range writes {
err := os.MkdirAll(filepath.Dir(wr.Path), 0o700)
if err == nil {
err = writeFileAtomic(wr.Path, []byte(wr.Secret), 0o600)
}
if err != nil {
p.Error = "Storing the secret: " + err.Error()
a.render(w, "dnsproviders", p)
return
}
}
if err := s.save(a.sys.settingsPath()); err != nil {
p.Error = "Saving: " + err.Error()
a.render(w, "dnsproviders", p)
return
}
for _, f := range deletes {
if strings.HasPrefix(f, a.sys.StateDir+"/") {
os.Remove(f)
}
}
back(w, r, "Saved. Apply to make the changes take effect.")
}
+85 -3
View File
@@ -78,8 +78,10 @@ func main() {
return
}
listen := flag.String("listen", ":443", "address to serve HTTPS on")
httpListen := flag.String("http", "", "address that redirects HTTP to HTTPS (empty: none)")
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
flag.Parse()
@@ -87,7 +89,7 @@ func main() {
assets = os.DirFS(*dev)
}
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
sys := System{ConfigDir: *configDir, StateDir: *stateDir, ACMEDir: *acmeDir}
// What was installed is confirmed; later router-confirm keeps this copy.
if _, err := os.Stat(sys.confirmedPath()); errors.Is(err, fs.ErrNotExist) {
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
@@ -105,7 +107,7 @@ func main() {
srv := &http.Server{
Addr: *listen,
Handler: newApp(sys, metrics, *dev != "").routes(),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
@@ -123,6 +125,18 @@ func main() {
defer cancel()
srv.Shutdown(shutdown)
}()
if *httpListen != "" {
plain := &http.Server{Addr: *httpListen, Handler: redirectToHTTPS(*listen), ReadHeaderTimeout: 10 * time.Second}
go func() {
if err := plain.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Printf("http redirect: %v", err)
}
}()
go func() {
<-ctx.Done()
plain.Close()
}()
}
log.Printf("listening on %s", *listen)
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
@@ -243,7 +257,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", "ddns", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "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 {
@@ -280,6 +294,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /apply", a.auth(a.apply))
mux.HandleFunc("POST /confirm", a.auth(a.confirm))
mux.HandleFunc("POST /discard", a.auth(a.discard))
mux.HandleFunc("POST /reboot", a.auth(a.reboot))
mux.HandleFunc("POST /password", a.auth(a.password))
mux.HandleFunc("GET /network", a.auth(a.networkPage))
mux.HandleFunc("POST /network", a.auth(a.network))
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
@@ -294,6 +310,10 @@ func (a *app) routes() http.Handler {
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 /dnsproviders", a.auth(a.providersPage))
mux.HandleFunc("POST /dnsproviders", a.auth(a.providersSave))
mux.HandleFunc("GET /certificate", a.auth(a.certificatePage))
mux.HandleFunc("POST /certificate", a.auth(a.certificateSave))
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
@@ -370,6 +390,25 @@ func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.Han
// form posts), then Origin; requests with neither (curl) pass.
var crossOrigin = http.NewCrossOriginProtection()
// redirectToHTTPS sends plain HTTP requests to the same host and path over
// HTTPS; httpsAddr is where the HTTPS server listens.
func redirectToHTTPS(httpsAddr string) http.Handler {
_, port, _ := net.SplitHostPort(httpsAddr)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if strings.Contains(host, ":") { // an IPv6 literal
host = "[" + strings.Trim(host, "[]") + "]"
}
if port != "" && port != "443" {
host += ":" + port
}
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
}
func sameOrigin(r *http.Request) bool {
return crossOrigin.Check(r) == nil
}
@@ -465,6 +504,49 @@ func (a *app) discard(w http.ResponseWriter, r *http.Request, _ session) {
back(w, r, "Changes discarded; the settings match the confirmed system again.")
}
func (a *app) reboot(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Reboot(); err != nil {
back(w, r, "Could not reboot: "+err.Error())
return
}
back(w, r, "Rebooting in a few seconds. This page works again when the router is back.")
}
// password changes the web UI's password and root's, which are one.
func (a *app) password(w http.ResponseWriter, r *http.Request, _ session) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
stored, err := os.ReadFile(a.sys.passwordPath())
ok := false
if err == nil {
ok, _ = checkPassword(string(stored), r.PostFormValue("current"))
}
next := r.PostFormValue("new")
switch {
case !ok:
time.Sleep(time.Second)
back(w, r, "The current password is wrong.")
case len(next) < 8:
back(w, r, "The new password must have at least 8 characters.")
case next != r.PostFormValue("confirm"):
back(w, r, "The new password and its confirmation differ.")
default:
if err := setRootPassword(a.sys, next); err != nil {
back(w, r, "Could not change the password: "+err.Error())
return
}
hash, err := hashPassword(next)
if err == nil {
err = writeFileAtomic(a.sys.passwordPath(), []byte(hash+"\n"), 0o600)
}
if err != nil {
back(w, r, "Root's password changed, the web UI's did not: "+err.Error())
return
}
back(w, r, "Password changed for the web UI and for root.")
}
}
type networkData struct {
Form Form
Errors FormErrors
+143
View File
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)
func newTestApp(t *testing.T) (*app, System) {
@@ -109,3 +110,145 @@ func TestLoginAndSave(t *testing.T) {
t.Fatalf("cross-origin POST: %d", rec.Code)
}
}
func TestAdvancedPageHasRebootButton(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
if body := do(h, "GET", "/advanced", nil, cookie).Body.String(); !strings.Contains(body, `action="/reboot"`) {
t.Fatalf("no reboot button:\n%s", body)
}
}
func TestPasswordChange(t *testing.T) {
a, sys := newTestApp(t)
var root string
setRootPassword = func(_ System, pw string) error { root = pw; return nil }
t.Cleanup(func() { setRootPassword = (func(System, string) error)(nil) })
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/advanced", nil, cookie).Body.String()
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
try := func(cur, next, confirm string) string {
rec := do(h, "POST", "/password", url.Values{"csrf": {token}, "current": {cur}, "new": {next}, "confirm": {confirm}}, cookie)
return rec.Header().Get("Location")
}
for name, loc := range map[string]string{
"wrong": try("nope", "newpassword1", "newpassword1"),
"short": try("hunter2hunter2", "short", "short"),
"mismatch": try("hunter2hunter2", "newpassword1", "newpassword2"),
} {
if root != "" || !strings.Contains(loc, "msg=") {
t.Fatalf("%s: root=%q location=%q", name, root, loc)
}
}
if stored, _ := os.ReadFile(sys.passwordPath()); func() bool { ok, _ := checkPassword(string(stored), "hunter2hunter2"); return !ok }() {
t.Fatal("a refused change altered the password")
}
if loc := try("hunter2hunter2", "newpassword1", "newpassword1"); !strings.Contains(loc, "Password+changed") {
t.Fatalf("location %q", loc)
}
stored, _ := os.ReadFile(sys.passwordPath())
if ok, _ := checkPassword(string(stored), "newpassword1"); !ok || root != "newpassword1" {
t.Errorf("web UI password not changed or root=%q", root)
}
}
func TestRedirectToHTTPS(t *testing.T) {
for _, c := range []struct{ addr, host, want string }{
{":443", "192.168.10.1", "https://192.168.10.1/login?x=1"},
{":443", "router.home:80", "https://router.home/login?x=1"},
{":8443", "router.home", "https://router.home:8443/login?x=1"},
{":443", "[fd00::1]:80", "https://[fd00::1]/login?x=1"},
} {
req := httptest.NewRequest("GET", "http://"+c.host+"/login?x=1", nil)
rec := httptest.NewRecorder()
redirectToHTTPS(c.addr).ServeHTTP(rec, req)
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != c.want {
t.Errorf("%s %s: %d %q, want %q", c.addr, c.host, rec.Code, rec.Header().Get("Location"), c.want)
}
}
}
func TestACMEIsChecked(t *testing.T) {
good := ACMEForm{Enabled: true, Provider: "cf", Domains: "router.example.org\n*.home.example.org", Email: "me@example.org", Staging: true, Agree: true}
mk := func() Settings {
return withProvider(withProvider(mustParse(t), "cf", "cloudflare"), "dyn", "dyndns2")
}
if err := mk().applyACME(good); err != nil {
t.Fatal(err)
}
for name, mod := range map[string]func(*ACMEForm){
"provider": func(f *ACMEForm) { f.Provider = "dyn" }, // cannot do DNS-01
"domains": func(f *ACMEForm) { f.Domains = "" },
"email": func(f *ACMEForm) { f.Email = "" },
"agree": func(f *ACMEForm) { f.Agree = false },
} {
f := good
mod(&f)
if err := mk().applyACME(f); err == nil || err.(FormErrors)[name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
if err := mk().applyACME(ACMEForm{}); err != nil {
t.Errorf("disabled and empty: %v", err)
}
}
func TestCertificatePage(t *testing.T) {
a, sys := newTestApp(t)
s, _ := loadSettings(sys.settingsPath())
withProvider(s, "cf", "cloudflare")
if err := s.save(sys.settingsPath()); err != nil {
t.Fatal(err)
}
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/certificate", nil, cookie).Body.String()
if !strings.Contains(body, `<option value="cf"`) {
t.Fatalf("the account is not offered:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "domains": {"router.example.org"}, "email": {"a@example.org"}}
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("without the agreement: %d", rec.Code)
}
form.Set("agree", "on")
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ = loadSettings(sys.settingsPath())
if f := s.acme(); !f.Enabled || f.Provider != "cf" || !f.Agree || f.Staging {
t.Fatalf("not saved: %+v", f)
}
}
func TestCertSource(t *testing.T) {
self, err := loadOrCreateCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
c := &certSource{fallback: self, dir: dir}
got, _ := c.get(nil)
if got != &c.fallback {
t.Fatal("served something else without files")
}
other := t.TempDir()
if _, err := loadOrCreateCert(other); err != nil {
t.Fatal(err)
}
for from, to := range map[string]string{"cert.pem": "fullchain.pem", "key.pem": "key.pem"} {
data, _ := os.ReadFile(filepath.Join(other, from))
os.WriteFile(filepath.Join(dir, to), data, 0o600)
}
if got, _ = c.get(nil); got == &c.fallback || string(got.Certificate[0]) == string(self.Certificate[0]) {
t.Fatal("did not pick up the certificate")
}
os.WriteFile(filepath.Join(dir, "key.pem"), []byte("junk"), 0o600)
future := time.Now().Add(time.Hour)
os.Chtimes(filepath.Join(dir, "key.pem"), future, future)
if got, _ = c.get(nil); got != &c.fallback {
t.Fatal("served a broken pair")
}
}
+1
View File
@@ -7,6 +7,7 @@ buildGo127Module {
fileset = lib.fileset.unions [
./go.mod
(lib.fileset.fileFilter (f: f.hasExt "go") ./.)
./providers.json
./templates
./static
];
+272
View File
@@ -0,0 +1,272 @@
{
"ddns": [
"1984",
"changeip",
"cloudflare",
"cloudns",
"ddns.fm",
"digitalocean",
"dinahosting",
"directnic",
"dnsexit2",
"dnsmadeeasy",
"domeneshop",
"dondominio",
"dslreports1",
"duckdns",
"dyndns1",
"dyndns2",
"easydns",
"enom",
"freedns",
"freemyip",
"gandi",
"godaddy",
"he.net",
"hetzner",
"infomaniak",
"inwx",
"keysystems",
"mythicdyn",
"namecheap",
"nfsn",
"njalla",
"noip",
"ovh",
"porkbun",
"regfishde",
"sitelutions",
"yandex",
"zoneedit1"
],
"acme": [
"abion",
"acmedns",
"active24",
"alidns",
"aliesa",
"allinkl",
"alwaysdata",
"anexia",
"artfiles",
"arvancloud",
"auroradns",
"autodns",
"axelname",
"azion",
"azuredns",
"baiducloud",
"beget",
"binarylane",
"bindman",
"bluecat",
"bluecatv2",
"bookmyname",
"bunny",
"checkdomain",
"civo",
"clouddns",
"cloudflare",
"cloudns",
"cloudru",
"com35",
"connbyte",
"conoha",
"conohav3",
"constellix",
"corenetworks",
"cpanel",
"curanet",
"czechia",
"dandomain",
"ddnss",
"derak",
"desec",
"designate",
"digitalocean",
"dinahosting",
"directadmin",
"dns51",
"dnscale",
"dnsexit",
"dnshomede",
"dnsimple",
"dnsla",
"dnsmadeeasy",
"dnsmint",
"dnsservices",
"dnsupdate",
"dode",
"domeneshop",
"dreamhost",
"duckdns",
"dyn",
"dynadot",
"dyndnsfree",
"dynu",
"easydns",
"edgecenter",
"edgedns",
"edgeone",
"efficientip",
"epik",
"eurodns",
"euserv",
"excedo",
"exoscale",
"f5xc",
"feno",
"fornex",
"freemyip",
"gandi",
"gandiv5",
"gcloud",
"gcore",
"gehirn",
"gigahostno",
"glesys",
"gname",
"godaddy",
"gravity",
"hetzner",
"hostingde",
"hostinger",
"hostingnl",
"hosttech",
"hostup",
"httpnet",
"httpreq",
"huaweicloud",
"hurricane",
"hyperone",
"ibmcloud",
"iijdpf",
"infoblox",
"infomaniak",
"internetbs",
"inwx",
"ionos",
"ionoscloud",
"ipv64",
"ispconfig",
"ispconfigddns",
"jdcloud",
"joker",
"katapult",
"keyhelp",
"leaseweb",
"liara",
"lightsail",
"limacity",
"linode",
"liquidweb",
"loopia",
"luadns",
"mailinabox",
"manageengine",
"metaname",
"metaregistrar",
"mijnhost",
"mittwald",
"myaddr",
"mydnsjp",
"myra",
"mythicbeasts",
"namecheap",
"namedotcom",
"namesilo",
"namesurfer",
"nearlyfreespeech",
"nederhost",
"neodigit",
"netcup",
"netlify",
"netnod",
"nexdns",
"ngenix",
"nicmanager",
"nicru",
"nifcloud",
"njalla",
"nodion",
"ns1",
"octenium",
"omglol",
"onecloudru",
"onlinenet",
"openprovider",
"opusdns",
"oraclecloud",
"otc",
"ovh",
"pdns",
"plesk",
"pointdns",
"porkbun",
"poweradmin",
"rackspace",
"rage4",
"rainyun",
"rcodezero",
"regfish",
"regru",
"rimuhosting",
"route53",
"safedns",
"sakuracloud",
"scaleway",
"scannet",
"selectel",
"selectelv2",
"selfhostde",
"servercow",
"shellrent",
"simply",
"sonic",
"spaceship",
"stackpath",
"syse",
"technitium",
"tele3",
"tencentcloud",
"timewebcloud",
"todaynic",
"transip",
"ucloud",
"ultradns",
"uniteddomains",
"variomedia",
"veesp",
"vegadns",
"vercel",
"versio",
"vinyldns",
"virtualname",
"vkcloud",
"volcengine",
"vscale",
"vultr",
"wannafind",
"webglobe",
"webnamesca",
"webnamesru",
"websupport",
"wedos",
"westcn",
"xinnet",
"yandex",
"yandex360",
"yandexcloud",
"zilore",
"zoneedit",
"zoneee",
"zonomi"
],
"acmeToken": {
"cloudflare": "CF_DNS_API_TOKEN_FILE",
"duckdns": "DUCKDNS_TOKEN_FILE",
"hetzner": "HETZNER_API_KEY_FILE",
"digitalocean": "DO_AUTH_TOKEN_FILE",
"desec": "DESEC_TOKEN_FILE"
}
}
+4 -1
View File
@@ -55,11 +55,14 @@ func TestReadSensors(t *testing.T) {
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", "": ""} {
for running, want := range map[string]string{"6.12.5": "", "6.12.5-zen1": "", "6.12.5.0": "", "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("7.3.0", "/nix/store/abc-linux-7.3/bzImage"); got != "" {
t.Errorf("7.3.0 is 7.3: %q", got)
}
if got := pendingKernel("6.12.4", "/boot/vmlinuz"); got != "" {
t.Errorf("an unknown path gave %q", got)
}
+13
View File
@@ -0,0 +1,13 @@
// Menus close on a click elsewhere; forms with data-confirm ask first.
// Close an open menu when the click is somewhere else.
document.addEventListener("click", function (e) {
document.querySelectorAll("nav details[open]").forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute("open");
});
});
// Ask before a form marked data-confirm is sent.
document.addEventListener("submit", function (e) {
var q = e.target.getAttribute("data-confirm");
if (q && !confirm(q)) e.preventDefault();
});
+6
View File
@@ -7,6 +7,12 @@ body { margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); backgr
header { display: flex; gap: 1.5rem; align-items: center; padding: .75rem 1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
nav form { display: inline; }
nav details { position: relative; }
nav summary { cursor: pointer; color: var(--accent); list-style: none; }
nav summary::-webkit-details-marker { display: none; }
nav summary::after { content: " \25BE"; }
nav details > div { position: absolute; z-index: 10; top: 1.6rem; left: -.75rem; min-width: 11rem; display: flex; flex-direction: column; gap: .35rem;
padding: .6rem .75rem; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, .15); }
a { color: var(--accent); }
main { max-width: 72rem; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.4rem; margin: .5rem 0 1rem; }
+35 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -22,6 +23,7 @@ type System struct {
ReleaseFile string
// LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty.
LeasesDNSDir string
ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any
}
func (s System) leasesDNSDir() string {
@@ -31,11 +33,10 @@ 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) ddnsSecretPath() string { return filepath.Join(s.StateDir, "ddns-secret") }
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 command(name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -122,6 +123,35 @@ func (s System) Apply() error {
return err
}
// Reboot restarts the router a few seconds from now, so the page that asked
// for it is still answered.
func (s System) Reboot() error {
_, err := command("systemd-run", "--quiet", "--on-active=3s", "systemctl", "reboot")
return err
}
func (s System) rootPasswordPath() string { return filepath.Join(s.StateDir, "root-password") }
// setRootPassword is replaced in tests.
var setRootPassword = func(s System, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "mkpasswd", "-s")
cmd.Stdin = strings.NewReader(password + "\n")
hash, err := cmd.Output()
if err != nil || !strings.HasPrefix(string(hash), "$") {
return fmt.Errorf("hashing the password for root: %v", err)
}
if err := writeFileAtomic(s.rootPasswordPath(), hash, 0o600); err != nil {
return err
}
// A unit does the part that needs more than the web UI may touch: /etc/shadow.
if out, err := command("systemctl", "start", "router-root-password.service"); err != nil {
return fmt.Errorf("setting root's password: %v %s", err, out)
}
return nil
}
func (s System) Confirm() error {
_, err := command("systemctl", "start", "--no-block", "router-confirm.service")
return err
+18
View File
@@ -21,6 +21,24 @@
</form>
</section>
<section>
<h2>Password</h2>
<p>One password for this web UI and for root on the console and over SSH.</p>
<form method="post" action="/password" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Current password <input type="password" name="current" autocomplete="current-password" required></label>
<label>New password (at least 8 characters) <input type="password" name="new" autocomplete="new-password" minlength="8" required></label>
<label>New password again <input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
<button>Change password</button>
</form>
</section>
<section>
<h2>Reboot</h2>
<p>Restarts the router; routing stops for a minute or two. A new kernel from an update only runs after this.</p>
<form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form>
</section>
<section>
<h2>Backup</h2>
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
@@ -0,0 +1,45 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}{{$f := .Form}}
<section>
<h2>Serving now</h2>
{{if .Has}}
<dl>
<dt>Certificate for</dt><dd>{{.Cert.Subject}}</dd>
<dt>Issued by</dt><dd>{{.Cert.Issuer}}{{if .Cert.SelfSigned}} <span class="bad">self-signed: browsers warn</span>{{end}}</dd>
<dt>Valid until</dt><dd>{{.Cert.NotAfter.Format "2006-01-02"}}</dd>
<dt>SHA-256</dt><dd><code>{{.Cert.Fingerprint}}</code></dd>
</dl>
{{else}}<p>No certificate yet; the web UI creates a self-signed one when it starts.</p>{{end}}
</section>
<form method="post" action="/certificate">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Let's Encrypt</legend>
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Get a certificate for the web UI and renew it</label>
<div>
<label>DNS account <select name="provider"><option value=""></option>
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
</select></label>
<label>Contact e-mail <input name="email" value="{{$f.Email}}" size="26" spellcheck="false" placeholder="you@example.org"></label>
</div>
{{range $k := list "provider" "email"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<p class="hint">The account must be able to change the DNS records of the names: Let's Encrypt is shown it by a TXT record, so no port
needs to be open to the internet. Accounts are kept on the <a href="/dnsproviders">DNS providers</a> page (cloudflare, desec,
digitalocean, duckdns and hetzner work here).{{if not .Providers}} <strong>There is none yet.</strong>{{end}}</p>
<label>Names, one per line; the first is the certificate's name <textarea name="domains" rows="4" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
<p class="hint">A wildcard like <code>*.home.example.org</code> works. The names should lead to the router (see DNS) so the browser reaches it by name.</p>
<label class="inline"><input type="checkbox" name="staging" {{if $f.Staging}}checked{{end}}> Test first with the staging service (its certificates are not trusted by browsers); uncheck for the real one</label>
<label class="inline"><input type="checkbox" name="agree" {{if $f.Agree}}checked{{end}}> I accept the <a href="https://letsencrypt.org/repository/">Let's Encrypt subscriber agreement</a></label>
{{with index $e "agree"}}<p class="field-error">{{.}}</p>{{end}}
</fieldset>
<button>Save</button>
</form>
<section>
<h2>Ordering</h2>
{{if .Status}}<p class="muted">{{.Status}}</p>{{if .Log}}<pre class="log">{{.Log}}</pre>{{end}}
{{else}}<p class="muted">Nothing yet. After you save and apply, the router orders the certificate (a minute or two); renewals follow 30 days before it expires and need no restart.</p>{{end}}
</section>
{{end}}
{{end}}
+1 -1
View File
@@ -33,7 +33,7 @@
<h2>System</h2>
<dl>
<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}}
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span> <a href="/advanced">Reboot</a>{{end}}</dd>{{end}}
<dt>Load</dt><dd>{{$sys.Load}}</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}}
+6 -12
View File
@@ -10,20 +10,14 @@
<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>Account <select name="provider"><option value=""></option>
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
</select></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>
{{range $k := list "provider" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<p class="hint">The account (login and token) is kept on the <a href="/dnsproviders">DNS providers</a> page, where certificates use it too.
{{if not .Providers}}<strong>There is none yet.</strong>{{end}}</p>
</fieldset>
<fieldset>
<legend>Names</legend>
@@ -0,0 +1,36 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<p class="hint">Accounts at DNS providers, kept once and used by <a href="/ddns">Dynamic DNS</a> (the providers ddclient knows) and by the
<a href="/certificate">certificate</a> (the providers lego knows). Secrets stay on the router in private files, not in the settings,
and are never shown again.<br>
<strong>Password or API token</strong>: for dynamic DNS, and for certificates through cloudflare, desec, digitalocean, duckdns and hetzner.<br>
<strong>Certificate variables</strong>: for certificates through any other provider, the variables lego asks for, one
<code>VARIABLE=value</code> per line (see <a href="https://go-acme.github.io/lego/dns/">the list of providers</a>), for example
<code>AWS_ACCESS_KEY_ID=…</code> and <code>AWS_SECRET_ACCESS_KEY=…</code> for route53.</p>
<form method="post" action="/dnsproviders">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="providers" value="{{len .Rows}}">
<div class="scroll">
<table>
<tr><th>Name</th><th>Provider</th><th>Login</th><th>Server</th><th>Password or API token</th><th>Certificate variables</th><th>Remove</th></tr>
{{range $i, $r := .Rows}}
<tr>
<td>{{if $r.New}}<input name="prov.{{$i}}.name" size="14" spellcheck="false" placeholder="cloudflare"><input type="hidden" name="prov.{{$i}}.new" value="1">
{{else}}{{$r.Name}}<input type="hidden" name="prov.{{$i}}.name" value="{{$r.Name}}">{{end}}</td>
<td><select name="prov.{{$i}}.type">{{range $r.Options}}<option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
<td><input name="prov.{{$i}}.username" value="{{$r.Username}}" size="12" spellcheck="false" autocomplete="off"></td>
<td><input name="prov.{{$i}}.server" value="{{$r.Server}}" size="18" spellcheck="false" placeholder="only dyndns2"></td>
<td><input type="password" name="prov.{{$i}}.secret" size="24" autocomplete="new-password" placeholder="{{if $r.SecretSet}}stored; leave empty to keep{{else if $r.New}}required{{else}}missing{{end}}"></td>
<td><textarea name="prov.{{$i}}.env" rows="2" cols="26" spellcheck="false" autocomplete="off" placeholder="{{if $r.EnvSet}}stored; leave empty to keep{{else}}VARIABLE=value, one per line{{end}}"></textarea></td>
<td>{{if not $r.New}}<input type="checkbox" name="prov.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "type" "username" "server" "secret" "env"}}{{with index $e (printf "prov[%d].%s" $i $f)}}<tr><td colspan="7" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). Fill in the last row to add an account.</p>
<button>Save</button>
</form>
{{end}}
{{end}}
+17 -8
View File
@@ -13,14 +13,22 @@
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
<a href="/ddns">Dynamic DNS</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<details name="menu"><summary>Network</summary><div>
<a href="/network">Interfaces</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
</div></details>
<details name="menu"><summary>Services</summary><div>
<a href="/ddns">Dynamic DNS</a>
<a href="/dnsproviders">DNS providers</a>
<a href="/certificate">Certificate</a>
</div></details>
<details name="menu"><summary>System</summary><div>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
</div></details>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
@@ -31,5 +39,6 @@
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{template "content" .}}
</main>
<script src="/static/menu.js"></script>
</body>
</html>
+2 -2
View File
@@ -8,7 +8,7 @@ mkdir -p "$dir/config"
wan=$(ip -j route show default 2>/dev/null | jq -r '.[0].dev // empty')
lan=$(ip -j link show 2>/dev/null | jq -r --arg wan "$wan" '[.[] | select(.link_type == "ether" and .ifname != $wan) | .ifname][0] // "eth1"')
cat > "$dir/config/settings.json" <<JSON
{"version": 1, "system": {"hostName": "dev"},
{"version": 2, "system": {"hostName": "dev"},
"router": {"wan": {"interface": "${wan:-eth0}", "ipv4": {"type": "dhcp"}},
"lans": {"lan": {"interface": "$lan", "internet": true, "ipv4": {"address": "192.168.10.1"},
"dhcp": {"pool": {"start": "192.168.10.100", "end": "192.168.10.200"}}}}}}
@@ -19,7 +19,7 @@ address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostn
192.168.10.101,52:54:00:12:34:56,,3600,$((now + 3000)),1,0,0,laptop,0,,0
192.168.10.120,52:54:00:aa:bb:cc,,3600,$((now + 1200)),1,0,0,phone,0,,0
LEASES
echo '{"version": "0.0.0-dev", "serial": 1, "schema": 1}' > "$dir/router-release"
echo '{"version": "0.0.0-dev", "serial": 1, "schema": 2}' > "$dir/router-release"
cd "$root/pkgs/router-webui"
echo devpassword1 | go run . passwd "$dir/webui-password"
echo
+143 -5
View File
@@ -313,10 +313,14 @@ let
ddnsValid = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/var/lib/router/dns-secrets/cf";
};
ddns = {
enable = true;
provider = "cf";
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
};
};
expect = [ ];
@@ -324,20 +328,39 @@ let
ddnsSecretInTheStore = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret";
};
ddns = {
enable = true;
provider = "cf";
domains = [ "home.example.org" ];
passwordFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret";
};
};
expect = [ "outside the Nix store" ];
};
ddnsWithoutNames = {
ddnsWithoutProvider = {
router = {
lans.main = lan { };
ddns = {
enable = true;
passwordFile = "/var/lib/router/ddns-secret";
provider = "nothing";
domains = [ "home.example.org" ];
};
};
expect = [ "must name a connection in router.dnsProviders" ];
};
ddnsWithoutNames = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/var/lib/router/dns-secrets/cf";
};
ddns = {
enable = true;
provider = "cf";
};
};
expect = [ "no domains to update" ];
@@ -345,15 +368,130 @@ let
ddnsWithoutAnyFamily = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/var/lib/router/dns-secrets/cf";
};
ddns = {
enable = true;
provider = "cf";
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
ipv4 = false;
};
};
expect = [ "neither IPv4 nor IPv6" ];
};
acmeValid = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/var/lib/router/dns-secrets/cf";
};
acme = {
enable = true;
provider = "cf";
domains = [
"router.example.org"
"*.home.example.org"
];
email = "me@example.org";
acceptTerms = true;
};
};
expect = [ ];
};
acmeProviderCannotDoDNS01 = {
router = {
lans.main = lan { };
dnsProviders.dyn = {
type = "dyndns2";
secretFile = "/var/lib/router/dns-secrets/dyn";
};
acme = {
enable = true;
provider = "dyn";
domains = [ "router.example.org" ];
email = "me@example.org";
acceptTerms = true;
};
};
expect = [ "lego has no DNS provider dyndns2" ];
};
acmeEnvironmentProviderValid = {
router = {
lans.main = lan { };
dnsProviders.aws = {
type = "route53";
environmentFile = "/var/lib/router/dns-secrets/aws.env";
};
acme = {
enable = true;
provider = "aws";
domains = [ "router.example.org" ];
email = "me@example.org";
acceptTerms = true;
};
};
expect = [ ];
};
acmeEnvironmentProviderWithoutFile = {
router = {
lans.main = lan { };
dnsProviders.aws.type = "route53";
acme = {
enable = true;
provider = "aws";
domains = [ "router.example.org" ];
email = "me@example.org";
acceptTerms = true;
};
};
expect = [ "needs environmentFile for certificates" ];
};
ddnsProviderUnknownToDdclient = {
router = {
lans.main = lan { };
dnsProviders.aws = {
type = "route53";
secretFile = "/var/lib/router/dns-secrets/aws";
};
ddns = {
enable = true;
provider = "aws";
domains = [ "home.example.org" ];
};
};
expect = [ "ddclient has no protocol route53" ];
};
acmeTermsNotAccepted = {
router = {
lans.main = lan { };
dnsProviders.cf = {
type = "cloudflare";
secretFile = "/var/lib/router/dns-secrets/cf";
};
acme = {
enable = true;
provider = "cf";
domains = [ "router.example.org" ];
email = "me@example.org";
};
};
expect = [ "subscriber agreement must be accepted" ];
};
acmeWithoutProvider = {
router = {
lans.main = lan { };
acme = {
enable = true;
domains = [ "router.example.org" ];
email = "me@example.org";
acceptTerms = true;
};
};
expect = [ "must name a connection in router.dnsProviders" ];
};
ruleSourcePortsWithoutProtocol = {
router = {
lans = {
+9 -6
View File
@@ -67,13 +67,16 @@ pkgs.testers.runNixOSTest {
end = "192.168.10.199";
};
};
dnsProviders.provider = {
type = "dyndns2";
server = "203.0.113.1";
username = "router";
secretFile = "/run/ddns-secret";
};
ddns = {
enable = true;
protocol = "dyndns2";
server = "203.0.113.1";
provider = "provider";
ssl = false;
username = "router";
passwordFile = "/run/ddns-secret";
domains = [
"home.example.test"
"vpn.example.test"
@@ -99,9 +102,9 @@ pkgs.testers.runNixOSTest {
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")
# One request for all names or one each, depending on the provider's answer.
for name in ("home.example.test", "vpn.example.test"):
assert f"hostname={name}" in out or name in out, out
upstream.wait_until_succeeds(f"grep -q '{name}.*myip=203.0.113.100' /var/log/nginx/access.log", timeout=30)
# 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")
+16 -6
View File
@@ -59,18 +59,28 @@ let
serial = 2000;
# Settings version 1: the time zone becomes explicit.
extra = ''
rm $out/migrations/*.jq
echo '.system.timeZone = "Europe/Berlin"' > $out/migrations/1.jq
'';
};
release = v: v.lib.mkRelease {
inherit pkgs;
# The router has all it needs to rebuild; keep the bundles small.
includeReference = false;
};
release =
v:
v.lib.mkRelease {
inherit pkgs;
# The router has all it needs to rebuild; keep the bundles small.
includeReference = false;
};
release1 = release v1;
release2 = release v2;
tool = v: r: "${v.lib.mkReleaseTool { inherit pkgs; release = r; }}/bin/router-release";
tool =
v: r:
"${
v.lib.mkReleaseTool {
inherit pkgs;
release = r;
}
}/bin/router-release";
settings = pkgs.writeText "settings.json" (
builtins.toJSON {
+31 -7
View File
@@ -87,6 +87,10 @@ pkgs.testers.runNixOSTest {
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=wrong {ui}/login") == "401"
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "303"
with subtest("plain HTTP is sent to HTTPS"):
out = client.succeed("curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://192.168.10.1/login?x=1")
assert out == "308 https://192.168.10.1/login?x=1", out
with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
post("/prefs", {"csrf": csrf("/advanced"), "pingTargets": "gateway, 192.168.10.50"})
# The UI pings from its sandbox (raw socket); the client answers.
@@ -144,20 +148,40 @@ pkgs.testers.runNixOSTest {
saved = router.succeed(f"cat {cfg}")
assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' 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",
with subtest("a DNS account is saved with its secret kept out of the settings, and dynamic DNS uses it"):
acct = {"providers": "1", "prov.0.new": "1", "prov.0.name": "cf", "prov.0.type": "cloudflare", "prov.0.username": "token"}
assert post("/dnsproviders", {"csrf": csrf("/dnsproviders"), **acct}) == "422"
assert post("/dnsproviders", {"csrf": csrf("/dnsproviders"), "prov.0.secret": "tok3n-abc", **acct}) == "303"
assert router.succeed("stat -c '%a' /var/lib/router/dns-secrets/cf").strip() == "600"
dd = {"enabled": "on", "provider": "cf", "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"
assert post("/ddns", {"csrf": csrf("/ddns"), **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")
assert '"home.example.org"' in saved and '"provider": "cf"' in saved and "tok3n-abc" not in saved, saved
assert "tok3n-abc" not in curl(f"{ui}/dnsproviders") + curl(f"{ui}/ddns")
with subtest("a certificate order is saved, and the page shows what is served"):
crt = {"enabled": "on", "provider": "cf", "domains": "router.example.org", "email": "me@example.org", "staging": "on"}
assert post("/certificate", {"csrf": csrf("/certificate"), **crt}) == "422"
assert post("/certificate", {"csrf": csrf("/certificate"), "agree": "on", **crt}) == "303"
assert '"acme"' in router.succeed(f"cat {cfg}")
assert "self-signed" in curl(f"{ui}/certificate")
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")
for f in ["router/config/settings.json", "router/webui-password", "router/webui/cert.pem", "router/webui/dashboard.json"]:
assert f in files.split(), files
with subtest("changing the password changes the web UI's and root's"):
pw = {"current": "wrong", "new": "hunter3hunter3", "confirm": "hunter3hunter3"}
curl(f"-o /dev/null -d csrf={csrf('/advanced')} " + " ".join(f"--data-urlencode {k}={v}" for k, v in pw.items()) + f" {ui}/password")
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter3hunter3 {ui}/login") == "401"
pw["current"] = "hunter2hunter2"
curl(f"-o /dev/null -d csrf={csrf('/advanced')} " + " ".join(f"--data-urlencode {k}={v}" for k, v in pw.items()) + f" {ui}/password")
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter3hunter3 {ui}/login") == "303"
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "401"
hashed = router.succeed("cat /var/lib/router/root-password").strip()
assert f"root:{hashed}:" in router.succeed("cat /etc/shadow"), hashed
'';
}