diff --git a/Cargo.lock b/Cargo.lock index 8abbc8a..32c6c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" dependencies = [ "anstyle", "anstyle-parse", @@ -43,36 +43,36 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -208,15 +208,15 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bytes" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cc" -version = "1.1.30" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16803a61b81d9eabb7eae2588776c4c1e584b738ede45fdbb4c972cec1e9945" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" dependencies = [ "shlex", ] @@ -237,6 +237,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-verbosity-flag" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e099138e1807662ff75e2cebe4ae2287add879245574489f9b1588eb5e5564ed" +dependencies = [ + "clap", + "log", +] + [[package]] name = "clap_builder" version = "4.5.20" @@ -269,9 +279,9 @@ checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console" @@ -436,9 +446,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" dependencies = [ "bytes", "futures-channel", @@ -507,9 +517,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.159" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "linked-hash-map" @@ -659,9 +669,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" @@ -671,9 +681,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.87" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] @@ -689,9 +699,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -767,9 +777,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" @@ -779,18 +789,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" dependencies = [ "proc-macro2", "quote", @@ -799,9 +809,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", "memchr", @@ -918,9 +928,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.79" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -962,18 +972,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.64" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.64" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" dependencies = [ "proc-macro2", "quote", @@ -992,9 +1002,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.40.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" dependencies = [ "backtrace", "bytes", @@ -1159,6 +1169,7 @@ dependencies = [ "axum-client-ip", "base64 0.22.1", "clap", + "clap-verbosity-flag", "http", "insta", "miette", @@ -1208,6 +1219,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 1622810..17fef08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,27 +7,28 @@ version = "0.3.0-dev" edition = "2021" [dependencies] -axum = "0.7.7" -axum-auth = { version = "0.7.0", default-features = false, features = [ +axum = "0.7" +axum-auth = { version = "0.7", default-features = false, features = [ "auth-basic", ] } -axum-client-ip = "0.6.1" -base64 = "0.22.1" -clap = { version = "4.5.20", features = ["derive", "env"] } -http = "1.1.0" -miette = { version = "7.2.0", features = ["fancy"] } -ring = { version = "0.17.8", features = ["std"] } -tokio = { version = "1.40.0", features = [ +axum-client-ip = "0.6" +base64 = "0.22" +clap = { version = "4", features = ["derive", "env"] } +clap-verbosity-flag = "2" +http = "1" +miette = { version = "7", features = ["fancy"] } +ring = { version = "0.17", features = ["std"] } +tokio = { version = "1", features = [ "macros", "rt", "process", "io-util", ] } -tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] -insta = "1.40.0" +insta = "1" [profile.dev] debug = 0 diff --git a/flake-modules/default.nix b/flake-modules/default.nix new file mode 100644 index 0000000..7a60543 --- /dev/null +++ b/flake-modules/default.nix @@ -0,0 +1,33 @@ +{ inputs, ... }: +{ + imports = [ + inputs.treefmt-nix.flakeModule + ./package.nix + ./overlay.nix + ./module.nix + ./tests.nix + ]; + + perSystem = + { pkgs, ... }: + { + # Setup formatters + treefmt = { + projectRootFile = "flake.nix"; + programs = { + nixfmt.enable = true; + rustfmt.enable = true; + statix.enable = true; + typos.enable = true; + }; + }; + + devShells.default = pkgs.mkShellNoCC { + packages = [ + pkgs.cargo-insta + pkgs.cargo-udeps + pkgs.mold + ]; + }; + }; +} diff --git a/flake-modules/module.nix b/flake-modules/module.nix new file mode 100644 index 0000000..6ffbba6 --- /dev/null +++ b/flake-modules/module.nix @@ -0,0 +1,196 @@ +let + module = + { + lib, + pkgs, + config, + ... + }: + let + cfg = config.services.webnsupdate; + inherit (lib) + mkOption + mkEnableOption + mkPackageOption + types + ; + in + { + options.services.webnsupdate = mkOption { + description = "An HTTP server for nsupdate."; + default = { }; + type = types.submodule { + options = { + enable = mkEnableOption "webnsupdate"; + extraArgs = mkOption { + description = '' + Extra arguments to be passed to the webnsupdate server command. + ''; + type = types.listOf types.str; + default = [ ]; + example = [ "--ip-source" ]; + }; + package = mkPackageOption pkgs "webnsupdate" { }; + bindIp = mkOption { + description = '' + IP address to bind to. + + Setting it to anything other than localhost is very insecure as + `webnsupdate` only supports plain HTTP and should always be behind a + reverse proxy. + ''; + type = types.str; + default = "localhost"; + example = "0.0.0.0"; + }; + bindPort = mkOption { + description = "Port to bind to."; + type = types.port; + default = 5353; + }; + passwordFile = mkOption { + description = '' + The file where the password is stored. + + This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`. + ''; + type = types.path; + example = "/secrets/webnsupdate.pass"; + }; + keyFile = mkOption { + description = '' + The TSIG key that `nsupdate` should use. + + This file will be passed to `nsupdate` through the `-k` option, so look + at `man 8 nsupdate` for information on the key's format. + ''; + type = types.path; + example = "/secrets/webnsupdate.key"; + }; + ttl = mkOption { + description = "The TTL that should be set on the zone records created by `nsupdate`."; + type = types.ints.positive; + default = 60; + example = 3600; + }; + records = mkOption { + description = '' + The fqdn of records that should be updated. + + Empty lines will be ignored, but whitespace will not be. + ''; + type = types.nullOr types.lines; + default = null; + example = '' + example.com. + + example.org. + ci.example.org. + ''; + }; + recordsFile = mkOption { + description = '' + The fqdn of records that should be updated. + + Empty lines will be ignored, but whitespace will not be. + ''; + type = types.nullOr types.path; + default = null; + example = "/secrets/webnsupdate.records"; + }; + user = mkOption { + description = "The user to run as."; + type = types.str; + default = "named"; + }; + group = mkOption { + description = "The group to run as."; + type = types.str; + default = "named"; + }; + }; + }; + }; + + config = + let + recordsFile = + if cfg.recordsFile != null then cfg.recordsFile else pkgs.writeText "webnsrecords" cfg.records; + args = lib.strings.escapeShellArgs ( + [ + "--records" + recordsFile + "--key-file" + cfg.keyFile + "--password-file" + cfg.passwordFile + "--address" + cfg.bindIp + "--port" + (builtins.toString cfg.bindPort) + "--ttl" + (builtins.toString cfg.ttl) + "--data-dir=%S/webnsupdate" + ] + ++ cfg.extraArgs + ); + cmd = "${lib.getExe cfg.package} ${args}"; + in + lib.mkIf cfg.enable { + # warnings = + # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsopported configuration."; + assertions = [ + { + assertion = + (cfg.records != null || cfg.recordsFile != null) + && !(cfg.records != null && cfg.recordsFile != null); + message = "Exactly one of `services.webnsupdate.records` and `services.webnsupdate.recordsFile` must be set."; + } + ]; + + systemd.services.webnsupdate = { + description = "Web interface for nsupdate."; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "bind.service" + ]; + preStart = "${cmd} verify"; + path = [ pkgs.dig ]; + startLimitIntervalSec = 60; + serviceConfig = { + ExecStart = [ cmd ]; + Type = "exec"; + Restart = "on-failure"; + RestartSec = "10s"; + # User and group + User = cfg.user; + Group = cfg.group; + # Runtime directory and mode + RuntimeDirectory = "webnsupdate"; + RuntimeDirectoryMode = "0750"; + # Cache directory and mode + CacheDirectory = "webnsupdate"; + CacheDirectoryMode = "0750"; + # Logs directory and mode + LogsDirectory = "webnsupdate"; + LogsDirectoryMode = "0750"; + # State directory and mode + StateDirectory = "webnsupdate"; + StateDirectoryMode = "0750"; + # New file permissions + UMask = "0027"; + # Security + NoNewPrivileges = true; + ProtectHome = true; + }; + }; + }; + }; +in +{ + flake.nixosModules = { + default = module; + webnsupdate = module; + }; +} diff --git a/flake-modules/overlay.nix b/flake-modules/overlay.nix new file mode 100644 index 0000000..fba3bc1 --- /dev/null +++ b/flake-modules/overlay.nix @@ -0,0 +1,5 @@ +{ + flake = { + overlays.default = _final: prev: { webnsupdate = prev.callPackage ../default.nix { }; }; + }; +} diff --git a/flake-modules/package.nix b/flake-modules/package.nix new file mode 100644 index 0000000..d4ff12a --- /dev/null +++ b/flake-modules/package.nix @@ -0,0 +1,24 @@ +{ + perSystem = + { pkgs, ... }: + { + packages = + let + webnsupdate = pkgs.callPackage ../default.nix { }; + in + { + inherit webnsupdate; + default = webnsupdate; + cargo-update = pkgs.writeShellApplication { + name = "cargo-update-lockfile"; + runtimeInputs = with pkgs; [ + cargo + gnused + ]; + text = '' + CARGO_TERM_COLOR=never cargo update 2>&1 | sed '/crates.io index/d' | tee -a cargo_update.log + ''; + }; + }; + }; +} diff --git a/flake-modules/tests.nix b/flake-modules/tests.nix new file mode 100644 index 0000000..7ec61ab --- /dev/null +++ b/flake-modules/tests.nix @@ -0,0 +1,144 @@ +{ self, ... }: +{ + perSystem = + { pkgs, self', ... }: + { + checks = + let + testDomain = "webnstest.example"; + dynamicZonesDir = "/var/lib/named/zones"; + 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 + ''; + + webnsupdate-machine = { + imports = [ self.nixosModules.webnsupdate ]; + + config = { + environment.systemPackages = [ + pkgs.dig + pkgs.curl + ]; + + services = { + webnsupdate = { + enable = true; + bindIp = "127.0.0.1"; + keyFile = "/etc/bind/rndc.key"; + # test:test (user:password) + passwordFile = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; + package = self'.packages.webnsupdate; + extraArgs = [ + "-vvv" # debug messages + "--ip-source=ConnectInfo" + ]; + records = '' + test1.${testDomain}. + test2.${testDomain}. + test3.${testDomain}. + ''; + }; + + bind = { + enable = true; + zones.${testDomain} = { + master = true; + file = "${dynamicZonesDir}/${testDomain}"; + extraConfig = '' + allow-update { key rndc-key; }; + ''; + }; + }; + }; + + systemd.services.bind.preStart = '' + # shellcheck disable=SC2211,SC1127 + rm -f ${dynamicZonesDir}/* # reset dynamic zones + + ${pkgs.coreutils}/bin/mkdir -m 0755 -p ${dynamicZonesDir} + chown "named" ${dynamicZonesDir} + chown "named" /var/lib/named + + # copy dynamic zone's file to the dynamic zones dir + cp ${zoneFile} ${dynamicZonesDir}/${testDomain} + ''; + }; + }; + in + { + module-test = pkgs.testers.runNixOSTest { + name = "webnsupdate-module"; + nodes.machine = webnsupdate-machine; + testScript = '' + machine.start(allow_reboot=True) + 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} | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + 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 + + # ensure webnsupdate managed records are missing + with subtest("query webnsupdate DNS records (fail)"): + machine.fail("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.fail("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.fail("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + 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") + + # ensure webnsupdate managed records are available + with subtest("query webnsupdate DNS records (succeed)"): + machine.succeed("dig @127.0.0.1 test1.${testDomain} | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + + machine.reboot() + machine.succeed("cat /var/lib/webnsupdate/last-ip") + machine.wait_for_unit("webnsupdate.service") + machine.succeed("cat /var/lib/webnsupdate/last-ip") + + # 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} | grep ^test1.${testDomain}") + machine.succeed("dig @127.0.0.1 test2.${testDomain} | grep ^test2.${testDomain}") + machine.succeed("dig @127.0.0.1 test3.${testDomain} | grep ^test3.${testDomain}") + ''; + }; + }; + }; +} diff --git a/flake.lock b/flake.lock index e6a68d4..e5a0f2a 100644 --- a/flake.lock +++ b/flake.lock @@ -39,7 +39,8 @@ "inputs": { "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", - "systems": "systems" + "systems": "systems", + "treefmt-nix": "treefmt-nix" } }, "systems": { @@ -56,6 +57,26 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1729613947, + "narHash": "sha256-XGOvuIPW1XRfPgHtGYXd5MAmJzZtOuwlfKDgxX5KT3s=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "aac86347fb5063960eccb19493e0cadcdb4205ca", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e01433f..3fb5460 100644 --- a/flake.nix +++ b/flake.nix @@ -7,73 +7,16 @@ url = "github:hercules-ci/flake-parts"; inputs.nixpkgs-lib.follows = "nixpkgs"; }; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ ./flake-modules ]; systems = import inputs.systems; - perSystem = - { - lib, - pkgs, - self', - ... - }: - { - packages = - let - webnsupdate = pkgs.callPackage ./default.nix { }; - in - { - inherit webnsupdate; - default = webnsupdate; - cargo-update = pkgs.writeShellApplication { - name = "cargo-update-lockfile"; - runtimeInputs = with pkgs; [ - cargo - gnused - ]; - text = '' - CARGO_TERM_COLOR=never cargo update 2>&1 | sed '/crates.io index/d' | tee -a cargo_update.log - ''; - }; - }; - - formatter = pkgs.nixfmt-rfc-style; - - checks = { - fmtRust = pkgs.callPackage ./run-cmd.nix { - src = inputs.self; - name = "fmt-rust"; - extraNativeBuildInputs = [ pkgs.rustfmt ]; - cmd = "${lib.getExe pkgs.cargo} fmt --all --check --verbose"; - }; - fmtNix = pkgs.callPackage ./run-cmd.nix { - src = inputs.self; - name = "fmt-nix"; - cmd = "${lib.getExe self'.formatter} --check ."; - }; - lintNix = pkgs.callPackage ./run-cmd.nix { - src = inputs.self; - name = "lint-nix"; - cmd = "${lib.getExe pkgs.statix} check ."; - }; - }; - - devShells.default = pkgs.mkShell { - packages = [ - pkgs.cargo-insta - pkgs.cargo-udeps - pkgs.mold - ]; - }; - }; - - flake = { - overlays.default = final: prev: { webnsupdate = final.callPackage ./default.nix { }; }; - - nixosModules.default = ./module.nix; - }; }; } diff --git a/module.nix b/module.nix deleted file mode 100644 index 203998f..0000000 --- a/module.nix +++ /dev/null @@ -1,177 +0,0 @@ -{ - lib, - pkgs, - config, - ... -}: -let - cfg = config.services.webnsupdate; - inherit (lib) mkOption mkEnableOption types; -in -{ - options.services.webnsupdate = mkOption { - description = "An HTTP server for nsupdate."; - default = { }; - type = types.submodule { - options = { - enable = mkEnableOption "webnsupdate"; - extraArgs = mkOption { - description = '' - Extra arguments to be passed to the webnsupdate server command. - ''; - type = types.listOf types.str; - default = [ ]; - example = [ "--ip-source" ]; - }; - bindIp = mkOption { - description = '' - IP address to bind to. - - Setting it to anything other than localhost is very insecure as - `webnsupdate` only supports plain HTTP and should always be behind a - reverse proxy. - ''; - type = types.str; - default = "localhost"; - example = "0.0.0.0"; - }; - bindPort = mkOption { - description = "Port to bind to."; - type = types.port; - default = 5353; - }; - passwordFile = mkOption { - description = '' - The file where the password is stored. - - This file can be created by running `webnsupdate mkpasswd $USERNAME $PASSWORD`. - ''; - type = types.path; - example = "/secrets/webnsupdate.pass"; - }; - keyFile = mkOption { - description = '' - The TSIG key that `nsupdate` should use. - - This file will be passed to `nsupdate` through the `-k` option, so look - at `man 8 nsupdate` for information on the key's format. - ''; - type = types.path; - example = "/secrets/webnsupdate.key"; - }; - ttl = mkOption { - description = "The TTL that should be set on the zone records created by `nsupdate`."; - type = types.ints.positive; - default = 60; - example = 3600; - }; - records = mkOption { - description = '' - The fqdn of records that should be updated. - - Empty lines will be ignored, but whitespace will not be. - ''; - type = types.nullOr types.lines; - default = null; - example = '' - example.com. - - example.org. - ci.example.org. - ''; - }; - recordsFile = mkOption { - description = '' - The fqdn of records that should be updated. - - Empty lines will be ignored, but whitespace will not be. - ''; - type = types.nullOr types.path; - default = null; - example = "/secrets/webnsupdate.records"; - }; - user = mkOption { - description = "The user to run as."; - type = types.str; - default = "named"; - }; - group = mkOption { - description = "The group to run as."; - type = types.str; - default = "named"; - }; - }; - }; - }; - - config = - let - recordsFile = - if cfg.recordsFile != null then cfg.recordsFile else pkgs.writeText "webnsrecords" cfg.records; - args = lib.strings.escapeShellArgs ( - [ - "--records" - recordsFile - "--key-file" - cfg.keyFile - "--password-file" - cfg.passwordFile - "--address" - cfg.bindIp - "--port" - (builtins.toString cfg.bindPort) - "--ttl" - (builtins.toString cfg.ttl) - ] - ++ cfg.extraArgs - ); - cmd = "${lib.getExe pkgs.webnsupdate} ${args}"; - in - lib.mkIf cfg.enable { - # warnings = - # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsopported configuration."; - assertions = [ - { - assertion = - (cfg.records != null || cfg.recordsFile != null) - && !(cfg.records != null && cfg.recordsFile != null); - message = "Exactly one of `services.webnsupdate.records` and `services.webnsupdate.recordsFile` must be set."; - } - ]; - - systemd.services.webnsupdate = { - description = "Web interface for nsupdate."; - wantedBy = [ "multi-user.target" ]; - after = [ - "network.target" - "bind.service" - ]; - preStart = "${cmd} verify"; - path = [ pkgs.dig ]; - startLimitIntervalSec = 60; - serviceConfig = { - ExecStart = [ cmd ]; - Type = "exec"; - Restart = "on-failure"; - RestartSec = "10s"; - # User and group - User = cfg.user; - Group = cfg.group; - # Runtime directory and mode - RuntimeDirectory = "webnsupdate"; - RuntimeDirectoryMode = "0750"; - # Cache directory and mode - CacheDirectory = "webnsupdate"; - CacheDirectoryMode = "0750"; - # Logs directory and mode - LogsDirectory = "webnsupdate"; - LogsDirectoryMode = "0750"; - # New file permissions - UMask = "0027"; - # Security - NoNewPrivileges = true; - ProtectHome = true; - }; - }; - }; -} diff --git a/run-cmd.nix b/run-cmd.nix deleted file mode 100644 index 02bbe9f..0000000 --- a/run-cmd.nix +++ /dev/null @@ -1,16 +0,0 @@ -{ - stdenvNoCC, - src, - name, - cmd, - extraBuildInputs ? [ ], - extraNativeBuildInputs ? [ ], -}: -stdenvNoCC.mkDerivation { - name = "${name}-src"; - inherit src; - buildInputs = extraBuildInputs; - nativeBuildInputs = extraNativeBuildInputs; - buildPhase = cmd; - installPhase = "mkdir $out"; -} diff --git a/src/main.rs b/src/main.rs index 19c1e66..7e2ffae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use axum_auth::AuthBasic; use axum_client_ip::{SecureClientIp, SecureClientIpSource}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use clap::{Parser, Subcommand}; +use clap_verbosity_flag::Verbosity; use http::StatusCode; use miette::{bail, ensure, Context, IntoDiagnostic, Result}; use tokio::io::AsyncWriteExt; @@ -26,6 +27,9 @@ const DEFAULT_SALT: &str = "UpdateMyDNS"; #[derive(Debug, Parser)] struct Opts { + #[command(flatten)] + verbosity: Verbosity, + /// Ip address of the server #[arg(long, default_value = "127.0.0.1")] address: IpAddr, @@ -121,6 +125,7 @@ struct AppState<'a> { } fn load_ip(path: &Path) -> Result> { + debug!("loading last IP from {}", path.display()); let data = match std::fs::read_to_string(path) { Ok(ip) => ip, Err(err) => { @@ -146,13 +151,28 @@ fn main() -> Result<()> { // parse cli arguments let mut args = Opts::parse(); + debug!("{args:?}"); // configure logger let subscriber = tracing_subscriber::FmtSubscriber::builder() .without_time() .with_env_filter( EnvFilter::builder() - .with_default_directive(LevelFilter::WARN.into()) + .with_default_directive( + if args.verbosity.is_present() { + match args.verbosity.log_level_filter() { + clap_verbosity_flag::LevelFilter::Off => LevelFilter::OFF, + clap_verbosity_flag::LevelFilter::Error => LevelFilter::ERROR, + clap_verbosity_flag::LevelFilter::Warn => LevelFilter::WARN, + clap_verbosity_flag::LevelFilter::Info => LevelFilter::INFO, + clap_verbosity_flag::LevelFilter::Debug => LevelFilter::DEBUG, + clap_verbosity_flag::LevelFilter::Trace => LevelFilter::TRACE, + } + } else { + LevelFilter::WARN + } + .into(), + ) .from_env_lossy(), ) .finish(); @@ -166,6 +186,7 @@ fn main() -> Result<()> { } let Opts { + verbosity: _, address: ip, port, password_file, @@ -287,6 +308,7 @@ async fn update_records( AuthBasic((username, pass)): AuthBasic, SecureClientIp(ip): SecureClientIp, ) -> axum::response::Result<&'static str> { + debug!("received update request from {ip}"); let Some(pass) = pass else { return Err((StatusCode::UNAUTHORIZED, Json::from("no password provided")).into()); }; @@ -309,9 +331,11 @@ async fn update_records( match nsupdate(ip, state.ttl, state.key_file, state.records).await { Ok(status) if status.success() => { tokio::task::spawn_blocking(move || { + info!("updating last ip to {ip}"); if let Err(err) = std::fs::write(state.ip_file, format!("{ip}")) { error!("Failed to update last IP: {err}"); } + info!("updated last ip to {ip}"); }); Ok("successful update") }