diff --git a/Cargo.lock b/Cargo.lock index 8e5a675..5967e09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,6 +409,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + [[package]] name = "hyper" version = "1.6.0" @@ -749,9 +755,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" dependencies = [ "bitflags", "errno", @@ -1177,6 +1183,7 @@ dependencies = [ "clap", "clap-verbosity-flag", "http", + "humantime", "insta", "miette", "ring", diff --git a/Cargo.toml b/Cargo.toml index 1022360..afbafbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ clap-verbosity-flag = { version = "3", default-features = false, features = [ "tracing", ] } http = "1" +humantime = "2.2.0" miette = { version = "7", features = ["fancy"] } ring = { version = "0.17", features = ["std"] } serde = { version = "1", features = ["derive"] } diff --git a/flake-modules/default.nix b/flake-modules/default.nix index 27ecc50..6736a16 100644 --- a/flake-modules/default.nix +++ b/flake-modules/default.nix @@ -3,10 +3,18 @@ imports = [ inputs.treefmt-nix.flakeModule ./package.nix - ./module.nix ./tests.nix ]; + flake.nixosModules = + let + webnsupdate = ../module.nix; + in + { + default = webnsupdate; + inherit webnsupdate; + }; + perSystem = { pkgs, ... }: { diff --git a/flake-modules/module.nix b/flake-modules/module.nix deleted file mode 100644 index ddfa934..0000000 --- a/flake-modules/module.nix +++ /dev/null @@ -1,222 +0,0 @@ -let - module = - { - lib, - pkgs, - config, - ... - }: - let - cfg = config.services.webnsupdate; - inherit (lib) - mkOption - mkEnableOption - mkPackageOption - types - ; - format = pkgs.formats.json { }; - in - { - # imports = [ - # (lib.mkRenamedOptionModule - # [ "services" "webnsupdate" "passwordFile" ] - # [ "services" "webnsupdate" "settings" "password_file" ] - # ) - # (lib.mkRenamedOptionModule - # [ "services" "webnsupdate" "keyFile" ] - # [ "services" "webnsupdate" "settings" "key_file" ] - # ) - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "allowedIPVersion" ] '' - # This option was replaced with 'services.webnsupdate.settings.ip_type' which defaults to Both. - # '') - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "bindIp" ] '' - # This option was replaced with 'services.webnsupdate.settings.address' which defaults to 127.0.0.1:5353. - # '') - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "bindPort" ] '' - # This option was replaced with 'services.webnsupdate.settings.address' which defaults to 127.0.0.1:5353. - # '') - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "records" ] '' - # This option was replaced with 'services.webnsupdate.settings.records' which defaults to []. - # '') - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "recordsFile" ] '' - # This option was replaced with 'services.webnsupdate.settings.records' which defaults to []. - # '') - # (lib.mkRemovedOptionModule [ "services" "webnsupdate" "ttl" ] '' - # This option was replaced with 'services.webnsupdate.settings.ttl' which defaults to 600s. - # '') - # ]; - - 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" { }; - settings = mkOption { - description = "The webnsupdate JSON configuration"; - default = { }; - type = types.submodule { - freeformType = format.type; - options = { - address = mkOption { - description = '' - IP address and port 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 = "127.0.0.1:5353"; - example = "[::1]:5353"; - }; - ip_type = mkOption { - description = ''The allowed IP versions to accept updates from.''; - type = types.enum [ - "Both" - "Ipv4Only" - "Ipv6Only" - ]; - default = "Both"; - example = "Ipv4Only"; - }; - password_file = 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"; - }; - key_file = 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`."; - default = { - secs = 600; - }; - example = { - secs = 600; - nanos = 50000; - }; - type = types.submodule { - options = { - secs = mkOption { - description = "The TTL (in seconds) that should be set on the zone records created by `nsupdate`."; - example = 3600; - }; - nanos = mkOption { - description = "The TTL (in nanoseconds) that should be set on the zone records created by `nsupdate`."; - default = 0; - example = 50000; - }; - }; - }; - }; - records = mkOption { - description = '' - The fqdn of records that should be updated. - - Empty lines will be ignored, but whitespace will not be. - ''; - type = types.listOf types.str; - default = [ ]; - example = [ - "example.com." - "example.org." - "ci.example.org." - ]; - }; - }; - }; - }; - 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 - configFile = format.generate "webnsupdate.json" cfg.settings; - args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs); - cmd = "${lib.getExe cfg.package} ${args}"; - in - lib.mkIf cfg.enable { - # FIXME: re-enable once I stop using the patched version of bind - # warnings = - # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration."; - - systemd.services.webnsupdate = { - description = "Web interface for nsupdate."; - wantedBy = [ "multi-user.target" ]; - after = [ - "network.target" - "bind.service" - ]; - preStart = "${lib.getExe cfg.package} verify ${configFile}"; - path = [ pkgs.dig ]; - startLimitIntervalSec = 60; - environment.DATA_DIR = "%S/webnsupdate"; - 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.lock b/flake.lock index b8717c3..1054085 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1741481578, - "narHash": "sha256-JBTSyJFQdO3V8cgcL08VaBUByEU6P5kXbTJN6R0PFQo=", + "lastModified": 1742317686, + "narHash": "sha256-ScJYnUykEDhYeCepoAWBbZWx2fpQ8ottyvOyGry7HqE=", "owner": "ipetkov", "repo": "crane", - "rev": "bb1c9567c43e4434f54e9481eb4b8e8e0d50f0b5", + "rev": "66cb0013f9a99d710b167ad13cbd8cc4e64f2ddb", "type": "github" }, "original": { @@ -37,11 +37,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1741851582, - "narHash": "sha256-cPfs8qMccim2RBgtKGF+x9IBCduRvd/N5F4nYpU0TVE=", + "lastModified": 1742069588, + "narHash": "sha256-C7jVfohcGzdZRF6DO+ybyG/sqpo1h6bZi9T56sxLy+k=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6607cf789e541e7873d40d3a8f7815ea92204f32", + "rev": "c80f6a7e10b39afcc1894e02ef785b1ad0b0d7e5", "type": "github" }, "original": { @@ -82,11 +82,11 @@ ] }, "locked": { - "lastModified": 1739829690, - "narHash": "sha256-mL1szCeIsjh6Khn3nH2cYtwO5YXG6gBiTw1A30iGeDU=", + "lastModified": 1742303424, + "narHash": "sha256-2R7cGdcA2npQQcIWu2cTlU63veTzwVZe78BliIuJT00=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "3d0579f5cc93436052d94b73925b48973a104204", + "rev": "b3b938ab8ba2e8a0ce9ee9b30ccfa5e903ae5753", "type": "github" }, "original": { diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..8bdfced --- /dev/null +++ b/module.nix @@ -0,0 +1,162 @@ +{ lib, pkgs, ... }@args: +let + cfg = args.config.services.webnsupdate; + inherit (lib) + mkOption + mkEnableOption + mkPackageOption + types + ; + format = pkgs.formats.json { }; +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" { }; + settings = mkOption { + description = "The webnsupdate JSON configuration"; + default = { }; + type = types.submodule { + freeformType = format.type; + options = { + address = mkOption { + description = '' + IP address and port 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 = "127.0.0.1:5353"; + example = "[::1]:5353"; + }; + ip_type = mkOption { + description = ''The allowed IP versions to accept updates from.''; + type = types.enum [ + "Both" + "Ipv4Only" + "Ipv6Only" + ]; + default = "Both"; + example = "Ipv4Only"; + }; + password_file = 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"; + }; + key_file = 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`."; + default = "10m"; + example = "60s"; + type = types.str; + }; + records = mkOption { + description = '' + The fqdn of records that should be updated. + + Empty lines will be ignored, but whitespace will not be. + ''; + type = types.listOf types.str; + default = [ ]; + example = [ + "example.com." + "example.org." + "ci.example.org." + ]; + }; + }; + }; + }; + 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 + configFile = format.generate "webnsupdate.json" cfg.settings; + args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs); + cmd = "${lib.getExe cfg.package} ${args}"; + in + lib.mkIf cfg.enable { + # FIXME: re-enable once I stop using the patched version of bind + # warnings = + # lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration."; + + systemd.services.webnsupdate = { + description = "Web interface for nsupdate."; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "bind.service" + ]; + preStart = "${lib.getExe cfg.package} verify ${configFile}"; + path = [ pkgs.dig ]; + startLimitIntervalSec = 60; + environment.DATA_DIR = "%S/webnsupdate"; + 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; + }; + }; + }; +} diff --git a/src/config.rs b/src/config.rs index e798661..6e4af7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,6 @@ use std::{ fs::File, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::PathBuf, - time::Duration, }; use axum_client_ip::SecureClientIpSource; @@ -75,8 +74,12 @@ pub struct Password { #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct Records { /// Time To Live (in seconds) to set on the DNS records - #[serde(default = "default_ttl")] - pub ttl: Duration, + #[serde( + default = "default_ttl", + serialize_with = "humantime_ser", + deserialize_with = "humantime_de" + )] + pub ttl: humantime::Duration, /// List of domain names for which to update the IP when an update is requested #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -124,6 +127,10 @@ pub struct Config { /// Records Configuration #[serde(flatten)] pub records: Records, + + /// The config schema (used for lsp completions) + #[serde(default, rename = "$schema", skip_serializing)] + pub _schema: serde::de::IgnoredAny, } impl Config { @@ -179,8 +186,8 @@ pub struct Invalid { // --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) --- -fn default_ttl() -> Duration { - super::DEFAULT_TTL +fn default_ttl() -> humantime::Duration { + super::DEFAULT_TTL.into() } fn default_salt() -> Box { @@ -199,6 +206,35 @@ fn default_ip_type() -> IpType { IpType::Both } +fn humantime_de<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl serde::de::Visitor<'_> for Visitor { + type Value = humantime::Duration; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a duration (e.g. 5s)") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map_err(E::custom) + } + } + de.deserialize_str(Visitor) +} + +fn humantime_ser(duration: &humantime::Duration, ser: S) -> Result +where + S: serde::Serializer, +{ + ser.serialize_str(&duration.to_string()) +} + #[test] fn default_values_config_snapshot() { let config: Config = serde_json::from_str("{}").unwrap(); diff --git a/src/main.rs b/src/main.rs index 9b24e99..aa44bc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -175,7 +175,7 @@ impl AppState<'static> { ); let state = AppState { - ttl: *ttl, + ttl: **ttl, records, // Load keyfile key_file: key_file