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:
@@ -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
@@ -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}";
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
./ddns.nix
|
||||
./acme.nix
|
||||
./rollback.nix
|
||||
./power.nix
|
||||
];
|
||||
|
||||
+108
-20
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user