Compare commits

..

3 commits

Author SHA1 Message Date
bfb923e841
wip: add config file to webnsupdate
Some checks failed
/ check-renovaterc (push) Successful in 3s
/ build (push) Successful in 1m8s
/ test (push) Successful in 1m46s
/ report-size (push) Has been cancelled
2025-03-19 23:02:27 +01:00
f207cbe859
chore(deps): lock file maintenance
All checks were successful
/ check-renovaterc (push) Successful in 2s
/ build (push) Successful in 2s
/ test (push) Successful in 12s
/ report-size (push) Successful in 7s
2025-03-18 22:00:32 +01:00
c589fb40c3
chore(deps): lock file maintenance
All checks were successful
/ check-renovaterc (push) Successful in 3s
/ build (push) Successful in 2s
/ test (push) Successful in 12s
/ report-size (push) Successful in 6s
2025-03-16 22:00:43 +01:00
8 changed files with 232 additions and 240 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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, ... }:
{

View file

@ -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;
};
}

18
flake.lock generated
View file

@ -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": {

162
module.nix Normal file
View file

@ -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;
};
};
};
}

View file

@ -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<str> {
@ -199,6 +206,35 @@ fn default_ip_type() -> IpType {
IpType::Both
}
fn humantime_de<'de, D>(de: D) -> Result<humantime::Duration, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
v.parse().map_err(E::custom)
}
}
de.deserialize_str(Visitor)
}
fn humantime_ser<S>(duration: &humantime::Duration, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(&duration.to_string())
}
#[test]
fn default_values_config_snapshot() {
let config: Config = serde_json::from_str("{}").unwrap();

View file

@ -175,7 +175,7 @@ impl AppState<'static> {
);
let state = AppState {
ttl: *ttl,
ttl: **ttl,
records,
// Load keyfile
key_file: key_file