diff --git a/flake-modules/module.nix b/flake-modules/module.nix index fc52da7..e70ae35 100644 --- a/flake-modules/module.nix +++ b/flake-modules/module.nix @@ -48,6 +48,16 @@ let type = types.port; default = 5353; }; + allowedIPVersion = mkOption { + description = ''The allowed IP versions to accept updates from.''; + type = types.enum [ + "both" + "ipv4-only" + "ipv6-only" + ]; + default = "both"; + example = "ipv4-only"; + }; passwordFile = mkOption { description = '' The file where the password is stored. @@ -126,6 +136,8 @@ let cfg.passwordFile "--address" cfg.bindIp + "--ip-type" + cfg.allowedIPVersion "--port" (builtins.toString cfg.bindPort) "--ttl" diff --git a/flake-modules/tests.nix b/flake-modules/tests.nix index 8c8a7d3..ce7934d 100644 --- a/flake-modules/tests.nix +++ b/flake-modules/tests.nix @@ -6,25 +6,25 @@ checks = let testDomain = "webnstest.example"; + lastIPPath = "/var/lib/webnsupdate/last-ip.json"; zoneFile = pkgs.writeText "${testDomain}.zoneinfo" '' - $ORIGIN . $TTL 60 ; 1 minute - ${testDomain} IN SOA ns1.${testDomain}. admin.${testDomain}. ( - 1 ; serial - 21600 ; refresh (6 hours) - 3600 ; retry (1 hour) - 604800 ; expire (1 week) - 86400) ; negative caching TTL (1 day) - - IN NS ns1.${testDomain}. $ORIGIN ${testDomain}. - ${testDomain}. IN A 127.0.0.1 - ${testDomain}. IN AAAA ::1 - ns1 IN A 127.0.0.1 - ns1 IN AAAA ::1 - nsupdate IN A 127.0.0.1 - nsupdate IN AAAA ::1 + @ IN SOA ns1.${testDomain}. admin.${testDomain}. ( + 1 ; serial + 6h ; refresh + 1h ; retry + 1w ; expire + 1d) ; negative caching TTL + + IN NS ns1.${testDomain}. + @ IN A 127.0.0.1 + ns1 IN A 127.0.0.1 + nsupdate IN A 127.0.0.1 + @ IN AAAA ::1 + ns1 IN AAAA ::1 + nsupdate IN AAAA ::1 ''; bindDynamicZone = @@ -121,6 +121,16 @@ }; }; + webnsupdate-ipv4-only-machine = { + imports = [ webnsupdate-nginx-machine ]; + config.services.webnsupdate.allowedIPVersion = "ipv4-only"; + }; + + webnsupdate-ipv6-only-machine = { + imports = [ webnsupdate-nginx-machine ]; + config.services.webnsupdate.allowedIPVersion = "ipv6-only"; + }; + testScript = '' machine.start(allow_reboot=True) machine.wait_for_unit("bind.service") @@ -128,8 +138,8 @@ # ensure base DNS records area available with subtest("query base DNS records"): - machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") - machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") # ensure webnsupdate managed records are missing @@ -140,7 +150,7 @@ with subtest("update webnsupdate DNS records (invalid auth)"): machine.fail("curl --fail --silent -u test1:test1 -X GET http://localhost:5353/update") - machine.fail("cat /var/lib/webnsupdate/last-ip") # no last-ip set yet + machine.fail("cat ${lastIPPath}") # no last-ip set yet # ensure webnsupdate managed records are missing with subtest("query webnsupdate DNS records (fail)"): @@ -150,7 +160,7 @@ with subtest("update webnsupdate DNS records (valid auth)"): machine.succeed("curl --fail --silent -u test:test -X GET http://localhost:5353/update") - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") # ensure webnsupdate managed records are available with subtest("query webnsupdate DNS records (succeed)"): @@ -159,9 +169,9 @@ machine.succeed("dig @127.0.0.1 test3.${testDomain} A test3.${testDomain} AAAA | grep ^test3.${testDomain}") machine.reboot() - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") machine.wait_for_unit("webnsupdate.service") - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") # ensure base DNS records area available after a reboot with subtest("query base DNS records"): @@ -197,8 +207,8 @@ # ensure base DNS records area available with subtest("query base DNS records"): - machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") - machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") # ensure webnsupdate managed records are missing @@ -212,7 +222,7 @@ with subtest("update webnsupdate DNS records (invalid auth)"): machine.fail("curl --fail --silent -u test1:test1 -X GET http://127.0.0.1/update") - machine.fail("cat /var/lib/webnsupdate/last-ip") # no last-ip set yet + machine.fail("cat ${lastIPPath}") # no last-ip set yet # ensure webnsupdate managed records are missing with subtest("query webnsupdate DNS records (fail)"): @@ -225,7 +235,7 @@ with subtest("update webnsupdate IPv4 DNS records (valid auth)"): machine.succeed("curl --fail --silent -u test:test -X GET http://127.0.0.1/update") - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") # ensure webnsupdate managed IPv4 records are available with subtest("query webnsupdate IPv4 DNS records (succeed)"): @@ -241,7 +251,7 @@ with subtest("update webnsupdate IPv6 DNS records (valid auth)"): machine.succeed("curl --fail --silent -u test:test -X GET http://[::1]/update") - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") # ensure webnsupdate managed IPv6 records are missing with subtest("query webnsupdate IPv6 DNS records (fail)"): @@ -250,9 +260,9 @@ machine.succeed("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") machine.reboot() - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") machine.wait_for_unit("webnsupdate.service") - machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.succeed("cat ${lastIPPath}") # ensure base DNS records area available after a reboot with subtest("query base DNS records"): @@ -270,6 +280,172 @@ machine.succeed("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") ''; }; + module-ipv4-only-test = pkgs.testers.runNixOSTest { + name = "webnsupdate-ipv4-only-module"; + nodes.machine = webnsupdate-ipv4-only-machine; + testScript = '' + machine.start(allow_reboot=True) + machine.wait_for_unit("bind.service") + machine.wait_for_unit("webnsupdate.service") + + # ensure base DNS records area available + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + with subtest("update webnsupdate DNS records (invalid auth)"): + machine.fail("curl --fail --silent -u test1:test1 -X GET http://127.0.0.1/update") + machine.fail("cat ${lastIPPath}") # no last-ip set yet + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + with subtest("update webnsupdate IPv6 DNS records (valid auth)"): + machine.fail("curl --fail --silent -u test:test -X GET http://[::1]/update") + machine.fail("cat ${lastIPPath}") + + # ensure webnsupdate managed IPv6 records are missing + with subtest("query webnsupdate IPv6 DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + with subtest("update webnsupdate IPv4 DNS records (valid auth)"): + machine.succeed("curl --fail --silent -u test:test -X GET http://127.0.0.1/update") + machine.succeed("cat ${lastIPPath}") + + # ensure webnsupdate managed IPv4 records are available + with subtest("query webnsupdate IPv4 DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + + # ensure webnsupdate managed IPv6 records are missing + with subtest("query webnsupdate IPv6 DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + machine.reboot() + machine.succeed("cat ${lastIPPath}") + machine.wait_for_unit("webnsupdate.service") + machine.succeed("cat ${lastIPPath}") + + # ensure base DNS records area available after a reboot + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are available after a reboot + with subtest("query webnsupdate DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + ''; + }; + module-ipv6-only-test = pkgs.testers.runNixOSTest { + name = "webnsupdate-ipv6-only-module"; + nodes.machine = webnsupdate-ipv6-only-machine; + testScript = '' + machine.start(allow_reboot=True) + machine.wait_for_unit("bind.service") + machine.wait_for_unit("webnsupdate.service") + + # ensure base DNS records area available + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + with subtest("update webnsupdate DNS records (invalid auth)"): + machine.fail("curl --fail --silent -u test1:test1 -X GET http://127.0.0.1/update") + machine.fail("cat ${lastIPPath}") # no last-ip set yet + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + with subtest("update webnsupdate IPv4 DNS records (valid auth)"): + machine.fail("curl --fail --silent -u test:test -X GET http://127.0.0.1/update") + machine.fail("cat ${lastIPPath}") + + # ensure webnsupdate managed IPv4 records are missing + with subtest("query webnsupdate IPv4 DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + + with subtest("update webnsupdate IPv6 DNS records (valid auth)"): + machine.succeed("curl --fail --silent -u test:test -X GET http://[::1]/update") + machine.succeed("cat ${lastIPPath}") + + # ensure webnsupdate managed IPv6 records are available + with subtest("query webnsupdate IPv6 DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + + # ensure webnsupdate managed IPv4 records are missing + with subtest("query webnsupdate IPv4 DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + + machine.reboot() + machine.succeed("cat ${lastIPPath}") + machine.wait_for_unit("webnsupdate.service") + machine.succeed("cat ${lastIPPath}") + + # ensure base DNS records area available after a reboot + with subtest("query base DNS records"): + machine.succeed("dig @127.0.0.1 ${testDomain} | grep ^${testDomain}") + machine.succeed("dig @127.0.0.1 ns1.${testDomain} | grep ^ns1.${testDomain}") + machine.succeed("dig @127.0.0.1 nsupdate.${testDomain} | grep ^nsupdate.${testDomain}") + + # ensure webnsupdate managed records are available after a reboot + with subtest("query webnsupdate DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} AAAA | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} AAAA | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} AAAA | grep ^test3.${testDomain}") + machine.fail("dig @127.0.0.1 test1.${testDomain} A | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} A | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} A | grep ^test3.${testDomain}") + ''; + }; }; }; } diff --git a/src/main.rs b/src/main.rs index d909d3c..922b00f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,10 +81,55 @@ struct Opts { #[clap(long, default_value = "RightmostXForwardedFor")] ip_source: SecureClientIpSource, + /// Set which IPs to allow updating + #[clap(long, default_value_t = IpType::Both)] + ip_type: IpType, + #[clap(subcommand)] subcommand: Option, } +#[derive(Debug, Default, Clone, Copy)] +enum IpType { + #[default] + Both, + IPv4Only, + IPv6Only, +} + +impl IpType { + fn valid_for_type(self, ip: IpAddr) -> bool { + match self { + IpType::Both => true, + IpType::IPv4Only => ip.is_ipv4(), + IpType::IPv6Only => ip.is_ipv6(), + } + } +} + +impl std::fmt::Display for IpType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IpType::Both => f.write_str("both"), + IpType::IPv4Only => f.write_str("ipv4-only"), + IpType::IPv6Only => f.write_str("ipv6-only"), + } + } +} + +impl std::str::FromStr for IpType { + type Err = miette::Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + "both" => Ok(Self::Both), + "ipv4-only" => Ok(Self::IPv4Only), + "ipv6-only" => Ok(Self::IPv6Only), + _ => bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"), + } + } +} + #[derive(Debug, Subcommand)] enum Cmd { Mkpasswd(password::Mkpasswd), @@ -117,6 +162,9 @@ struct AppState<'a> { /// Last recorded IPs last_ips: std::sync::Arc>, + + /// The IP type for which to allow updates + ip_type: IpType, } #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] @@ -173,13 +221,14 @@ impl AppState<'static> { salt: _, ttl, ip_source: _, + ip_type, } = args; // Set state let ttl = Duration::from_secs(*ttl); // Use last registered IP address if available - let ip_file = Box::leak(data_dir.join("last-ip").into_boxed_path()); + let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path()); let state = AppState { ttl, @@ -198,6 +247,7 @@ impl AppState<'static> { }) .transpose()?, ip_file, + ip_type: *ip_type, last_ips: std::sync::Arc::new(tokio::sync::Mutex::new( load_ip(ip_file)?.unwrap_or_default(), )), @@ -276,6 +326,7 @@ fn main() -> Result<()> { salt, ttl: _, ip_source, + ip_type, } = args; info!("checking environment"); @@ -312,6 +363,10 @@ fn main() -> Result<()> { // Update DNS record with previous IPs (if available) let ips = state.last_ips.lock().await.clone(); for ip in ips.ips() { + if !ip_type.valid_for_type(ip) { + continue; + } + match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await { Ok(status) => { if !status.success() { @@ -361,6 +416,13 @@ async fn update_records( SecureClientIp(ip): SecureClientIp, ) -> axum::response::Result<&'static str> { info!("accepted update from {ip}"); + + if !state.ip_type.valid_for_type(ip) { + let ip_type = state.ip_type; + tracing::warn!("rejecting update from {ip} as we are running a {ip_type} filter"); + return Err((StatusCode::CONFLICT, format!("running in {ip_type} mode")).into()); + } + match nsupdate::nsupdate(ip, state.ttl, state.key_file, state.records).await { Ok(status) if status.success() => { let ips = {