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:
+143
-5
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user