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
+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";