Compare commits
17 commits
push-tprzx
...
main
Author | SHA1 | Date | |
---|---|---|---|
593bee9024 | |||
09345f2193 | |||
71d1e43ef2 | |||
528aad1d8e | |||
cb7e4d554b | |||
01f53b2bf0 | |||
60662ff1f0 | |||
eaed7b2302 | |||
8a04c2726f | |||
1a88dbaeb2 | |||
c41008f800 | |||
3c18f07a2a | |||
0a5348097d | |||
bdb27d7cb1 | |||
41c30372fb | |||
e99bc52de2 | |||
338e296683 |
16 changed files with 458 additions and 582 deletions
83
Cargo.lock
generated
83
Cargo.lock
generated
|
@ -185,9 +185,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.12"
|
version = "1.2.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2"
|
checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
@ -200,9 +200,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.28"
|
version = "4.5.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff"
|
checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
|
@ -220,9 +220,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.27"
|
version = "4.5.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7"
|
checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
@ -304,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nonempty",
|
"nonempty",
|
||||||
"thiserror 1.0.69",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -454,7 +454,6 @@ dependencies = [
|
||||||
"linked-hash-map",
|
"linked-hash-map",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"serde",
|
|
||||||
"similar",
|
"similar",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -502,9 +501,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.25"
|
version = "0.4.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
|
checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
|
@ -543,7 +542,7 @@ dependencies = [
|
||||||
"supports-unicode",
|
"supports-unicode",
|
||||||
"terminal_size",
|
"terminal_size",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"thiserror 1.0.69",
|
"thiserror",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -566,9 +565,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.3"
|
version = "0.8.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924"
|
checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adler2",
|
"adler2",
|
||||||
]
|
]
|
||||||
|
@ -623,9 +622,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owo-colors"
|
name = "owo-colors"
|
||||||
version = "4.1.0"
|
version = "4.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56"
|
checksum = "c1338d6deb23bc10a7767b72185570dd73cb3616188eec1088e19b5835f8cabb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
|
@ -729,15 +728,14 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.8"
|
version = "0.17.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"libc",
|
"libc",
|
||||||
"spin",
|
|
||||||
"untrusted",
|
"untrusted",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
@ -775,18 +773,18 @@ checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.217"
|
version = "1.0.218"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
|
checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.217"
|
version = "1.0.218"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
|
checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
@ -795,9 +793,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.138"
|
version = "1.0.139"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
|
checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
@ -859,9 +857,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.13.2"
|
version = "1.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
|
@ -873,12 +871,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
@ -949,16 +941,7 @@ version = "1.0.69"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 1.0.69",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "2.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 2.0.11",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -972,17 +955,6 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "2.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
|
@ -1128,9 +1100,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.16"
|
version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034"
|
checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-linebreak"
|
name = "unicode-linebreak"
|
||||||
|
@ -1183,7 +1155,6 @@ dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.11",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
cargo-features = ["codegen-backend"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
|
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
|
||||||
name = "webnsupdate"
|
name = "webnsupdate"
|
||||||
|
@ -27,14 +29,13 @@ miette = { version = "7", features = ["fancy"] }
|
||||||
ring = { version = "0.17", features = ["std"] }
|
ring = { version = "0.17", features = ["std"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
|
||||||
tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] }
|
tokio = { version = "1", features = ["macros", "rt", "process", "io-util"] }
|
||||||
tower-http = { version = "0.6", features = ["validate-request"] }
|
tower-http = { version = "0.6", features = ["validate-request"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1", features = ["json"] }
|
insta = "1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
|
@ -45,3 +46,4 @@ codegen-units = 1
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = 0
|
debug = 0
|
||||||
|
codegen-backend = "cranelift"
|
||||||
|
|
|
@ -14,38 +14,8 @@ let
|
||||||
mkPackageOption
|
mkPackageOption
|
||||||
types
|
types
|
||||||
;
|
;
|
||||||
format = pkgs.formats.json { };
|
|
||||||
in
|
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 {
|
options.services.webnsupdate = mkOption {
|
||||||
description = "An HTTP server for nsupdate.";
|
description = "An HTTP server for nsupdate.";
|
||||||
default = { };
|
default = { };
|
||||||
|
@ -61,35 +31,34 @@ let
|
||||||
example = [ "--ip-source" ];
|
example = [ "--ip-source" ];
|
||||||
};
|
};
|
||||||
package = mkPackageOption pkgs "webnsupdate" { };
|
package = mkPackageOption pkgs "webnsupdate" { };
|
||||||
settings = mkOption {
|
bindIp = mkOption {
|
||||||
description = "The webnsupdate JSON configuration";
|
|
||||||
default = { };
|
|
||||||
type = types.submodule {
|
|
||||||
freeformType = format.type;
|
|
||||||
options = {
|
|
||||||
address = mkOption {
|
|
||||||
description = ''
|
description = ''
|
||||||
IP address and port to bind to.
|
IP address to bind to.
|
||||||
|
|
||||||
Setting it to anything other than localhost is very
|
Setting it to anything other than localhost is very insecure as
|
||||||
insecure as `webnsupdate` only supports plain HTTP and
|
`webnsupdate` only supports plain HTTP and should always be behind a
|
||||||
should always be behind a reverse proxy.
|
reverse proxy.
|
||||||
'';
|
'';
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "127.0.0.1:5353";
|
default = "localhost";
|
||||||
example = "[::1]:5353";
|
example = "0.0.0.0";
|
||||||
};
|
};
|
||||||
ip_type = mkOption {
|
bindPort = mkOption {
|
||||||
|
description = "Port to bind to.";
|
||||||
|
type = types.port;
|
||||||
|
default = 5353;
|
||||||
|
};
|
||||||
|
allowedIPVersion = mkOption {
|
||||||
description = ''The allowed IP versions to accept updates from.'';
|
description = ''The allowed IP versions to accept updates from.'';
|
||||||
type = types.enum [
|
type = types.enum [
|
||||||
"Both"
|
"both"
|
||||||
"Ipv4Only"
|
"ipv4-only"
|
||||||
"Ipv6Only"
|
"ipv6-only"
|
||||||
];
|
];
|
||||||
default = "Both";
|
default = "both";
|
||||||
example = "Ipv4Only";
|
example = "ipv4-only";
|
||||||
};
|
};
|
||||||
password_file = mkOption {
|
passwordFile = mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
The file where the password is stored.
|
The file where the password is stored.
|
||||||
|
|
||||||
|
@ -98,7 +67,7 @@ let
|
||||||
type = types.path;
|
type = types.path;
|
||||||
example = "/secrets/webnsupdate.pass";
|
example = "/secrets/webnsupdate.pass";
|
||||||
};
|
};
|
||||||
key_file = mkOption {
|
keyFile = mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
The TSIG key that `nsupdate` should use.
|
The TSIG key that `nsupdate` should use.
|
||||||
|
|
||||||
|
@ -110,43 +79,34 @@ let
|
||||||
};
|
};
|
||||||
ttl = mkOption {
|
ttl = mkOption {
|
||||||
description = "The TTL that should be set on the zone records created by `nsupdate`.";
|
description = "The TTL that should be set on the zone records created by `nsupdate`.";
|
||||||
default = {
|
type = types.ints.positive;
|
||||||
secs = 600;
|
default = 60;
|
||||||
};
|
|
||||||
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;
|
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 {
|
records = mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
The fqdn of records that should be updated.
|
The fqdn of records that should be updated.
|
||||||
|
|
||||||
Empty lines will be ignored, but whitespace will not be.
|
Empty lines will be ignored, but whitespace will not be.
|
||||||
'';
|
'';
|
||||||
type = types.listOf types.str;
|
type = types.nullOr types.lines;
|
||||||
default = [ ];
|
default = null;
|
||||||
example = [
|
example = ''
|
||||||
"example.com."
|
example.com.
|
||||||
"example.org."
|
|
||||||
"ci.example.org."
|
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 {
|
user = mkOption {
|
||||||
description = "The user to run as.";
|
description = "The user to run as.";
|
||||||
|
@ -164,14 +124,41 @@ let
|
||||||
|
|
||||||
config =
|
config =
|
||||||
let
|
let
|
||||||
configFile = format.generate "webnsupdate.json" cfg.settings;
|
recordsFile =
|
||||||
args = lib.strings.escapeShellArgs ([ "--config=${configFile}" ] ++ cfg.extraArgs);
|
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
|
||||||
|
"--ip-type"
|
||||||
|
cfg.allowedIPVersion
|
||||||
|
"--port"
|
||||||
|
(builtins.toString cfg.bindPort)
|
||||||
|
"--ttl"
|
||||||
|
(builtins.toString cfg.ttl)
|
||||||
|
"--data-dir=%S/webnsupdate"
|
||||||
|
]
|
||||||
|
++ cfg.extraArgs
|
||||||
|
);
|
||||||
cmd = "${lib.getExe cfg.package} ${args}";
|
cmd = "${lib.getExe cfg.package} ${args}";
|
||||||
in
|
in
|
||||||
lib.mkIf cfg.enable {
|
lib.mkIf cfg.enable {
|
||||||
# FIXME: re-enable once I stop using the patched version of bind
|
|
||||||
# warnings =
|
# warnings =
|
||||||
# lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported configuration.";
|
# lib.optional (!config.services.bind.enable) "`webnsupdate` is expected to be used alongside `bind`. This is an unsupported 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 = {
|
systemd.services.webnsupdate = {
|
||||||
description = "Web interface for nsupdate.";
|
description = "Web interface for nsupdate.";
|
||||||
|
@ -180,10 +167,9 @@ let
|
||||||
"network.target"
|
"network.target"
|
||||||
"bind.service"
|
"bind.service"
|
||||||
];
|
];
|
||||||
preStart = "${lib.getExe cfg.package} verify ${configFile}";
|
preStart = "${cmd} verify";
|
||||||
path = [ pkgs.dig ];
|
path = [ pkgs.dig ];
|
||||||
startLimitIntervalSec = 60;
|
startLimitIntervalSec = 60;
|
||||||
environment.DATA_DIR = "%S/webnsupdate";
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = [ cmd ];
|
ExecStart = [ cmd ];
|
||||||
Type = "exec";
|
Type = "exec";
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
lastIPPath = "/var/lib/webnsupdate/last-ip.json";
|
lastIPPath = "/var/lib/webnsupdate/last-ip.json";
|
||||||
|
|
||||||
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
|
zoneFile = pkgs.writeText "${testDomain}.zoneinfo" ''
|
||||||
$TTL 600 ; 10 minutes
|
$TTL 60 ; 1 minute
|
||||||
$ORIGIN ${testDomain}.
|
$ORIGIN ${testDomain}.
|
||||||
@ IN SOA ns1.${testDomain}. admin.${testDomain}. (
|
@ IN SOA ns1.${testDomain}. admin.${testDomain}. (
|
||||||
1 ; serial
|
1 ; serial
|
||||||
|
@ -73,19 +73,20 @@
|
||||||
|
|
||||||
webnsupdate = {
|
webnsupdate = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
bindIp = lib.mkDefault "127.0.0.1";
|
||||||
|
keyFile = "/etc/bind/rndc.key";
|
||||||
|
# test:test (user:password)
|
||||||
|
passwordFile = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA";
|
||||||
package = self'.packages.webnsupdate;
|
package = self'.packages.webnsupdate;
|
||||||
extraArgs = [ "-vvv" ]; # debug messages
|
extraArgs = [
|
||||||
settings = {
|
"-vvv" # debug messages
|
||||||
address = lib.mkDefault "127.0.0.1:5353";
|
"--ip-source=ConnectInfo"
|
||||||
key_file = "/etc/bind/rndc.key";
|
|
||||||
password_file = pkgs.writeText "webnsupdate.pass" "FQoNmuU1BKfg8qsU96F6bK5ykp2b0SLe3ZpB3nbtfZA"; # test:test
|
|
||||||
ip_source = lib.mkDefault "ConnectInfo";
|
|
||||||
records = [
|
|
||||||
"test1.${testDomain}."
|
|
||||||
"test2.${testDomain}."
|
|
||||||
"test3.${testDomain}."
|
|
||||||
];
|
];
|
||||||
};
|
records = ''
|
||||||
|
test1.${testDomain}.
|
||||||
|
test2.${testDomain}.
|
||||||
|
test3.${testDomain}.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -96,7 +97,7 @@
|
||||||
webnsupdate-ipv4-machine
|
webnsupdate-ipv4-machine
|
||||||
];
|
];
|
||||||
|
|
||||||
config.services.webnsupdate.settings.address = "[::1]:5353";
|
config.services.webnsupdate.bindIp = "::1";
|
||||||
};
|
};
|
||||||
|
|
||||||
webnsupdate-nginx-machine =
|
webnsupdate-nginx-machine =
|
||||||
|
@ -108,26 +109,26 @@
|
||||||
|
|
||||||
config.services = {
|
config.services = {
|
||||||
# Use default IP Source
|
# Use default IP Source
|
||||||
webnsupdate.settings.ip_source = "RightmostXForwardedFor";
|
webnsupdate.extraArgs = lib.mkForce [ "-vvv" ]; # debug messages
|
||||||
|
|
||||||
nginx = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
recommendedProxySettings = true;
|
recommendedProxySettings = true;
|
||||||
|
|
||||||
virtualHosts.webnsupdate.locations."/".proxyPass =
|
virtualHosts.webnsupdate.locations."/".proxyPass =
|
||||||
"http://${config.services.webnsupdate.settings.address}";
|
"http://${config.services.webnsupdate.bindIp}:${builtins.toString config.services.webnsupdate.bindPort}";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
webnsupdate-ipv4-only-machine = {
|
webnsupdate-ipv4-only-machine = {
|
||||||
imports = [ webnsupdate-nginx-machine ];
|
imports = [ webnsupdate-nginx-machine ];
|
||||||
config.services.webnsupdate.settings.ip_type = "Ipv4Only";
|
config.services.webnsupdate.allowedIPVersion = "ipv4-only";
|
||||||
};
|
};
|
||||||
|
|
||||||
webnsupdate-ipv6-only-machine = {
|
webnsupdate-ipv6-only-machine = {
|
||||||
imports = [ webnsupdate-nginx-machine ];
|
imports = [ webnsupdate-nginx-machine ];
|
||||||
config.services.webnsupdate.settings.ip_type = "Ipv6Only";
|
config.services.webnsupdate.allowedIPVersion = "ipv6-only";
|
||||||
};
|
};
|
||||||
|
|
||||||
# "A" for IPv4, "AAAA" for IPv6, "ANY" for any
|
# "A" for IPv4, "AAAA" for IPv6, "ANY" for any
|
||||||
|
@ -157,9 +158,9 @@
|
||||||
STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"]
|
STATIC_DOMAINS: list[str] = ["${testDomain}", "ns1.${testDomain}", "nsupdate.${testDomain}"]
|
||||||
DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"]
|
DYNAMIC_DOMAINS: list[str] = ["test1.${testDomain}", "test2.${testDomain}", "test3.${testDomain}"]
|
||||||
|
|
||||||
def dig_cmd(domain: str, record: str, ip: str | None) -> tuple[str, str]:
|
def dig_cmd(domain: str, record: str, ip: str | None) -> str:
|
||||||
match_ip = "" if ip is None else f"\\s\\+600\\s\\+IN\\s\\+{record}\\s\\+{ip}$"
|
match_ip = "" if ip is None else f"\\s\\+60\\s\\+IN\\s\\+{record}\\s\\+{ip}$"
|
||||||
return f"dig @localhost {record} {domain} +noall +answer", f"grep '^{domain}.{match_ip}'"
|
return f"dig @localhost {record} {domain} +noall +answer | grep '^{domain}.{match_ip}'"
|
||||||
|
|
||||||
def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str:
|
def curl_cmd(domain: str, identity: str, path: str, query: dict[str, str]) -> str:
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
@ -167,16 +168,10 @@
|
||||||
return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'"
|
return f"{CURL} -u {identity} -X GET 'http://{domain}{"" if NGINX else ":5353"}/{path}{q}'"
|
||||||
|
|
||||||
def domain_available(domain: str, record: str, ip: str | None=None):
|
def domain_available(domain: str, record: str, ip: str | None=None):
|
||||||
dig, grep = dig_cmd(domain, record, ip)
|
machine.succeed(dig_cmd(domain, record, ip))
|
||||||
rc, output = machine.execute(dig)
|
|
||||||
print(f"{dig}[{rc}]: {output}")
|
|
||||||
machine.succeed(f"{dig} | {grep}")
|
|
||||||
|
|
||||||
def domain_missing(domain: str, record: str, ip: str | None=None):
|
def domain_missing(domain: str, record: str, ip: str | None=None):
|
||||||
dig, grep = dig_cmd(domain, record, ip)
|
machine.fail(dig_cmd(domain, record, ip))
|
||||||
rc, output = machine.execute(dig)
|
|
||||||
print(f"{dig}[{rc}]: {output}")
|
|
||||||
machine.fail(f"{dig} | {grep}")
|
|
||||||
|
|
||||||
def update_records(domain: str="localhost", /, *, path: str="update", **kwargs):
|
def update_records(domain: str="localhost", /, *, path: str="update", **kwargs):
|
||||||
machine.succeed(curl_cmd(domain, "test:test", path, kwargs))
|
machine.succeed(curl_cmd(domain, "test:test", path, kwargs))
|
||||||
|
|
18
flake.lock
generated
18
flake.lock
generated
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1738652123,
|
"lastModified": 1739936662,
|
||||||
"narHash": "sha256-zdZek5FXK/k95J0vnLF0AMnYuZl4AjARq83blKuJBYY=",
|
"narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "c7e015a5fcefb070778c7d91734768680188a9cd",
|
"rev": "19de14aaeb869287647d9461cbd389187d8ecdb7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -37,11 +37,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1738680400,
|
"lastModified": 1739866667,
|
||||||
"narHash": "sha256-ooLh+XW8jfa+91F1nhf9OF7qhuA/y1ChLx6lXDNeY5U=",
|
"narHash": "sha256-EO1ygNKZlsAC9avfcwHkKGMsmipUk1Uc0TbrEZpkn64=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "799ba5bffed04ced7067a91798353d360788b30d",
|
"rev": "73cf49b8ad837ade2de76f87eb53fc85ed5d4680",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -82,11 +82,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1738680491,
|
"lastModified": 1739829690,
|
||||||
"narHash": "sha256-8X7tR3kFGkE7WEF5EXVkt4apgaN85oHZdoTGutCFs6I=",
|
"narHash": "sha256-mL1szCeIsjh6Khn3nH2cYtwO5YXG6gBiTw1A30iGeDU=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "64dbb922d51a42c0ced6a7668ca008dded61c483",
|
"rev": "3d0579f5cc93436052d94b73925b48973a104204",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
217
src/config.rs
217
src/config.rs
|
@ -1,217 +0,0 @@
|
||||||
use std::{
|
|
||||||
fs::File,
|
|
||||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
|
||||||
path::PathBuf,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use axum_client_ip::SecureClientIpSource;
|
|
||||||
use miette::{Context, IntoDiagnostic};
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub enum IpType {
|
|
||||||
#[default]
|
|
||||||
Both,
|
|
||||||
Ipv4Only,
|
|
||||||
Ipv6Only,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IpType {
|
|
||||||
pub 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<Self, Self::Err> {
|
|
||||||
match s {
|
|
||||||
"both" => Ok(Self::Both),
|
|
||||||
"ipv4-only" => Ok(Self::Ipv4Only),
|
|
||||||
"ipv6-only" => Ok(Self::Ipv6Only),
|
|
||||||
_ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Webserver settings
|
|
||||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub struct Server {
|
|
||||||
/// Ip address and port of the server
|
|
||||||
#[serde(default = "default_address")]
|
|
||||||
pub address: SocketAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Password settings
|
|
||||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub struct Password {
|
|
||||||
/// File containing password to match against
|
|
||||||
///
|
|
||||||
/// Should be of the format `username:password` and contain a single password
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub password_file: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Salt to get more unique hashed passwords and prevent table based attacks
|
|
||||||
#[serde(default = "default_salt")]
|
|
||||||
pub salt: Box<str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records settings
|
|
||||||
#[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,
|
|
||||||
|
|
||||||
/// List of domain names for which to update the IP when an update is requested
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
#[allow(clippy::struct_field_names)]
|
|
||||||
pub records: Vec<Box<str>>,
|
|
||||||
|
|
||||||
/// If provided, when an IPv6 prefix is provided with an update, this will be used to derive
|
|
||||||
/// the full IPv6 address of the client
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub client_id: Option<Ipv6Addr>,
|
|
||||||
|
|
||||||
/// If a client id is provided the ipv6 update will be ignored (only the prefix will be used).
|
|
||||||
/// This domain will point to the ipv6 address instead of the address derived from the client
|
|
||||||
/// id (usually this is the router).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub router_domain: Option<Box<str>>,
|
|
||||||
|
|
||||||
/// Set client IP source
|
|
||||||
///
|
|
||||||
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html>
|
|
||||||
#[serde(default = "default_ip_source")]
|
|
||||||
pub ip_source: SecureClientIpSource,
|
|
||||||
|
|
||||||
/// Set which IPs to allow updating (ipv4, ipv6 or both)
|
|
||||||
#[serde(default = "default_ip_type")]
|
|
||||||
pub ip_type: IpType,
|
|
||||||
|
|
||||||
/// Keyfile `nsupdate` should use
|
|
||||||
///
|
|
||||||
/// If specified, then `webnsupdate` must have read access to the file
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub key_file: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
||||||
pub struct Config {
|
|
||||||
/// Server Configuration
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub server: Server,
|
|
||||||
|
|
||||||
/// Password Configuration
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub password: Password,
|
|
||||||
|
|
||||||
/// Records Configuration
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub records: Records,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Load the configuration without verifying it
|
|
||||||
pub fn load(path: &std::path::Path) -> miette::Result<Self> {
|
|
||||||
serde_json::from_reader::<File, Self>(
|
|
||||||
File::open(path)
|
|
||||||
.into_diagnostic()
|
|
||||||
.wrap_err_with(|| format!("failed open {}", path.display()))?,
|
|
||||||
)
|
|
||||||
.into_diagnostic()
|
|
||||||
.wrap_err_with(|| format!("failed to load configuration from {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure only a verified configuration is returned
|
|
||||||
pub fn verified(self) -> miette::Result<Self> {
|
|
||||||
self.verify()?;
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify the configuration
|
|
||||||
pub fn verify(&self) -> Result<(), Invalid> {
|
|
||||||
let mut invalid_records: Vec<miette::Error> = self
|
|
||||||
.records
|
|
||||||
.records
|
|
||||||
.iter()
|
|
||||||
.filter_map(|record| crate::records::validate_record_str(record).err())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
invalid_records.extend(
|
|
||||||
self.records
|
|
||||||
.router_domain
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|domain| crate::records::validate_record_str(domain).err()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let err = Invalid { invalid_records };
|
|
||||||
|
|
||||||
if err.invalid_records.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
|
|
||||||
#[error("the configuration was invalid")]
|
|
||||||
pub struct Invalid {
|
|
||||||
#[related]
|
|
||||||
pub invalid_records: Vec<miette::Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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_salt() -> Box<str> {
|
|
||||||
super::DEFAULT_SALT.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_address() -> SocketAddr {
|
|
||||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_ip_source() -> SecureClientIpSource {
|
|
||||||
SecureClientIpSource::RightmostXForwardedFor
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_ip_type() -> IpType {
|
|
||||||
IpType::Both
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_values_config_snapshot() {
|
|
||||||
let config: Config = serde_json::from_str("{}").unwrap();
|
|
||||||
insta::assert_json_snapshot!(config, @r#"
|
|
||||||
{
|
|
||||||
"address": "127.0.0.1:5353",
|
|
||||||
"salt": "UpdateMyDNS",
|
|
||||||
"ttl": {
|
|
||||||
"secs": 60,
|
|
||||||
"nanos": 0
|
|
||||||
},
|
|
||||||
"ip_source": "RightmostXForwardedFor",
|
|
||||||
"ip_type": "Both"
|
|
||||||
}
|
|
||||||
"#);
|
|
||||||
}
|
|
215
src/main.rs
215
src/main.rs
|
@ -10,18 +10,16 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use axum_client_ip::SecureClientIp;
|
use axum_client_ip::{SecureClientIp, SecureClientIpSource};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use clap_verbosity_flag::Verbosity;
|
use clap_verbosity_flag::Verbosity;
|
||||||
use config::Config;
|
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use miette::{bail, ensure, Context, IntoDiagnostic, Result};
|
use miette::{bail, ensure, Context, IntoDiagnostic, Result};
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
|
||||||
mod nsupdate;
|
mod nsupdate;
|
||||||
mod password;
|
mod password;
|
||||||
mod records;
|
mod records;
|
||||||
|
@ -34,52 +32,120 @@ struct Opts {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
verbosity: Verbosity<clap_verbosity_flag::InfoLevel>,
|
verbosity: Verbosity<clap_verbosity_flag::InfoLevel>,
|
||||||
|
|
||||||
|
/// Ip address of the server
|
||||||
|
#[arg(long, default_value = "127.0.0.1")]
|
||||||
|
address: IpAddr,
|
||||||
|
|
||||||
|
/// Port of the server
|
||||||
|
#[arg(long, default_value_t = 5353)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// File containing password to match against
|
||||||
|
///
|
||||||
|
/// Should be of the format `username:password` and contain a single password
|
||||||
|
#[arg(long)]
|
||||||
|
password_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Salt to get more unique hashed passwords and prevent table based attacks
|
||||||
|
#[arg(long, default_value = DEFAULT_SALT)]
|
||||||
|
salt: String,
|
||||||
|
|
||||||
|
/// Time To Live (in seconds) to set on the DNS records
|
||||||
|
#[arg(long, default_value_t = DEFAULT_TTL.as_secs())]
|
||||||
|
ttl: u64,
|
||||||
|
|
||||||
/// Data directory
|
/// Data directory
|
||||||
#[arg(long, env, default_value = ".")]
|
#[arg(long, default_value = ".")]
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
|
|
||||||
|
/// File containing the records that should be updated when an update request is made
|
||||||
|
///
|
||||||
|
/// There should be one record per line:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// example.com.
|
||||||
|
/// mail.example.com.
|
||||||
|
/// ```
|
||||||
|
#[arg(long)]
|
||||||
|
records: PathBuf,
|
||||||
|
|
||||||
|
/// Keyfile `nsupdate` should use
|
||||||
|
///
|
||||||
|
/// If specified, then `webnsupdate` must have read access to the file
|
||||||
|
#[arg(long)]
|
||||||
|
key_file: Option<PathBuf>,
|
||||||
|
|
||||||
/// Allow not setting a password
|
/// Allow not setting a password
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
|
|
||||||
#[clap(flatten)]
|
/// Set client IP source
|
||||||
config_or_command: ConfigOrCommand,
|
///
|
||||||
}
|
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html>
|
||||||
|
#[clap(long, default_value = "RightmostXForwardedFor")]
|
||||||
|
ip_source: SecureClientIpSource,
|
||||||
|
|
||||||
#[derive(clap::Args, Debug)]
|
/// Set which IPs to allow updating
|
||||||
#[group(multiple = false)]
|
#[clap(long, default_value_t = IpType::Both)]
|
||||||
struct ConfigOrCommand {
|
ip_type: IpType,
|
||||||
/// Path to the configuration file
|
|
||||||
#[arg(long, short)]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
|
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
subcommand: Option<Cmd>,
|
subcommand: Option<Cmd>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigOrCommand {
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
pub fn take(&mut self) -> (Option<PathBuf>, Option<Cmd>) {
|
enum IpType {
|
||||||
(self.config.take(), self.subcommand.take())
|
#[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<Self, Self::Err> {
|
||||||
|
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)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
Mkpasswd(password::Mkpasswd),
|
Mkpasswd(password::Mkpasswd),
|
||||||
/// Verify the configuration file
|
/// Verify the records file
|
||||||
Verify {
|
Verify,
|
||||||
/// Path to the configuration file
|
|
||||||
config: PathBuf,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd {
|
impl Cmd {
|
||||||
pub fn process(self, args: &Opts) -> Result<()> {
|
pub fn process(self, args: &Opts) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args),
|
Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args),
|
||||||
Cmd::Verify { config } => config::Config::load(&config) // load config
|
Cmd::Verify => records::load(&args.records).map(drop),
|
||||||
.and_then(Config::verified) // verify config
|
|
||||||
.map(drop), // ignore config data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,7 +168,7 @@ struct AppState<'a> {
|
||||||
last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>,
|
last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>,
|
||||||
|
|
||||||
/// The IP type for which to allow updates
|
/// The IP type for which to allow updates
|
||||||
ip_type: config::IpType,
|
ip_type: IpType,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
@ -145,38 +211,33 @@ impl SavedIPs {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState<'static> {
|
impl AppState<'static> {
|
||||||
fn from_args(args: &Opts, config: &config::Config) -> miette::Result<Self> {
|
fn from_args(args: &Opts) -> miette::Result<Self> {
|
||||||
let Opts {
|
let Opts {
|
||||||
verbosity: _,
|
verbosity: _,
|
||||||
|
address: _,
|
||||||
|
port: _,
|
||||||
|
password_file: _,
|
||||||
data_dir,
|
data_dir,
|
||||||
|
key_file,
|
||||||
insecure,
|
insecure,
|
||||||
config_or_command: _,
|
subcommand: _,
|
||||||
} = args;
|
|
||||||
|
|
||||||
let config::Records {
|
|
||||||
ttl,
|
|
||||||
records,
|
records,
|
||||||
client_id: _,
|
salt: _,
|
||||||
router_domain: _,
|
ttl,
|
||||||
ip_source: _,
|
ip_source: _,
|
||||||
ip_type,
|
ip_type,
|
||||||
key_file,
|
} = args;
|
||||||
} = &config.records;
|
|
||||||
|
// Set state
|
||||||
|
let ttl = Duration::from_secs(*ttl);
|
||||||
|
|
||||||
// Use last registered IP address if available
|
// Use last registered IP address if available
|
||||||
let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path());
|
let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path());
|
||||||
|
|
||||||
// Leak DNS records
|
|
||||||
let records: &[&str] = &*Vec::leak(
|
|
||||||
records
|
|
||||||
.iter()
|
|
||||||
.map(|record| &*Box::leak(record.clone()))
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
ttl: *ttl,
|
ttl,
|
||||||
records,
|
// Load DNS records
|
||||||
|
records: records::load_no_verify(records)?,
|
||||||
// Load keyfile
|
// Load keyfile
|
||||||
key_file: key_file
|
key_file: key_file
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
@ -279,37 +340,34 @@ fn main() -> Result<()> {
|
||||||
|
|
||||||
debug!("{args:?}");
|
debug!("{args:?}");
|
||||||
|
|
||||||
let config = match args.config_or_command.take() {
|
|
||||||
// process subcommand
|
// process subcommand
|
||||||
(None, Some(cmd)) => return cmd.process(&args),
|
if let Some(cmd) = args.subcommand.take() {
|
||||||
(Some(path), None) => {
|
return cmd.process(&args);
|
||||||
let config = config::Config::load(&path)?;
|
|
||||||
if let Err(err) = config.verify() {
|
|
||||||
error!("failed to verify configuration: {err}");
|
|
||||||
}
|
}
|
||||||
config
|
|
||||||
}
|
|
||||||
(None, None) | (Some(_), Some(_)) => unreachable!(
|
|
||||||
"bad state, one of config or subcommand should be available (clap should enforce this)"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize state
|
// Initialize state
|
||||||
let state = AppState::from_args(&args, &config)?;
|
let state = AppState::from_args(&args)?;
|
||||||
|
|
||||||
let Opts {
|
let Opts {
|
||||||
verbosity: _,
|
verbosity: _,
|
||||||
|
address: ip,
|
||||||
|
port,
|
||||||
|
password_file,
|
||||||
data_dir: _,
|
data_dir: _,
|
||||||
|
key_file: _,
|
||||||
insecure,
|
insecure,
|
||||||
config_or_command: _,
|
subcommand: _,
|
||||||
|
records: _,
|
||||||
|
salt,
|
||||||
|
ttl: _,
|
||||||
|
ip_source,
|
||||||
|
ip_type,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
info!("checking environment");
|
info!("checking environment");
|
||||||
|
|
||||||
// Load password hash
|
// Load password hash
|
||||||
let password_hash = config
|
let password_hash = password_file
|
||||||
.password
|
|
||||||
.password_file
|
|
||||||
.map(|path| -> miette::Result<_> {
|
.map(|path| -> miette::Result<_> {
|
||||||
let path = path.as_path();
|
let path = path.as_path();
|
||||||
let pass = std::fs::read_to_string(path).into_diagnostic()?;
|
let pass = std::fs::read_to_string(path).into_diagnostic()?;
|
||||||
|
@ -340,13 +398,11 @@ fn main() -> Result<()> {
|
||||||
// Update DNS record with previous IPs (if available)
|
// Update DNS record with previous IPs (if available)
|
||||||
let ips = state.last_ips.lock().await.clone();
|
let ips = state.last_ips.lock().await.clone();
|
||||||
|
|
||||||
let mut actions = ips
|
let actions = ips
|
||||||
.ips()
|
.ips()
|
||||||
.filter(|ip| config.records.ip_type.valid_for_type(*ip))
|
.filter(|ip| ip_type.valid_for_type(*ip))
|
||||||
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records))
|
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records));
|
||||||
.peekable();
|
|
||||||
|
|
||||||
if actions.peek().is_some() {
|
|
||||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
|
@ -361,30 +417,24 @@ fn main() -> Result<()> {
|
||||||
.wrap_err("failed to update records with previous IP");
|
.wrap_err("failed to update records with previous IP");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Create services
|
// Create services
|
||||||
let app = Router::new().route("/update", get(update_records));
|
let app = Router::new().route("/update", get(update_records));
|
||||||
// if a password is provided, validate it
|
// if a password is provided, validate it
|
||||||
let app = if let Some(pass) = password_hash {
|
let app = if let Some(pass) = password_hash {
|
||||||
app.layer(auth::layer(
|
app.layer(auth::layer(Box::leak(pass), String::leak(salt)))
|
||||||
Box::leak(pass),
|
|
||||||
Box::leak(config.password.salt),
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
.layer(config.records.ip_source.into_extension())
|
.layer(ip_source.into_extension())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let config::Server { address } = config.server;
|
|
||||||
|
|
||||||
// Start services
|
// Start services
|
||||||
info!("starting listener on {address}");
|
info!("starting listener on {ip}:{port}");
|
||||||
let listener = tokio::net::TcpListener::bind(address)
|
let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port))
|
||||||
.await
|
.await
|
||||||
.into_diagnostic()?;
|
.into_diagnostic()?;
|
||||||
info!("listening on {address}");
|
info!("listening on {ip}:{port}");
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
@ -523,15 +573,6 @@ async fn trigger_update(
|
||||||
state: &AppState<'static>,
|
state: &AppState<'static>,
|
||||||
) -> axum::response::Result<&'static str> {
|
) -> axum::response::Result<&'static str> {
|
||||||
let actions = nsupdate::Action::from_records(ip, state.ttl, state.records);
|
let actions = nsupdate::Action::from_records(ip, state.ttl, state.records);
|
||||||
|
|
||||||
if actions.len() == 0 {
|
|
||||||
return Err((
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Nothing to do (e.g. we are ipv4-only but an ipv6 update was requested)",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||||
Ok(status) if status.success() => {
|
Ok(status) if status.success() => {
|
||||||
let ips = {
|
let ips = {
|
||||||
|
|
|
@ -25,7 +25,7 @@ impl<'a> Action<'a> {
|
||||||
to: IpAddr,
|
to: IpAddr,
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
records: &'a [&'a str],
|
records: &'a [&'a str],
|
||||||
) -> impl IntoIterator<Item = Self> + std::iter::ExactSizeIterator + 'a {
|
) -> impl IntoIterator<Item = Self> + 'a {
|
||||||
records
|
records
|
||||||
.iter()
|
.iter()
|
||||||
.map(move |&domain| Action::Reassign { domain, to, ttl })
|
.map(move |&domain| Action::Reassign { domain, to, ttl })
|
||||||
|
@ -91,7 +91,7 @@ fn update_ns_records<'a>(
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
writeln!(buf, "server 127.0.0.1")?;
|
writeln!(buf, "server 127.0.0.1")?;
|
||||||
for action in actions {
|
for action in actions {
|
||||||
write!(buf, "{action}")?;
|
writeln!(buf, "{action}")?;
|
||||||
}
|
}
|
||||||
writeln!(buf, "send")?;
|
writeln!(buf, "send")?;
|
||||||
writeln!(buf, "quit")
|
writeln!(buf, "quit")
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
//! records
|
//! records
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::PathBuf;
|
use std::path::Path;
|
||||||
|
|
||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
use miette::{Context, IntoDiagnostic, Result};
|
use miette::{Context, IntoDiagnostic, Result};
|
||||||
|
@ -20,18 +20,11 @@ pub struct Mkpasswd {
|
||||||
|
|
||||||
/// The password
|
/// The password
|
||||||
password: String,
|
password: String,
|
||||||
|
|
||||||
/// An application specific value
|
|
||||||
#[arg(long, default_value = crate::DEFAULT_SALT)]
|
|
||||||
salt: String,
|
|
||||||
|
|
||||||
/// The file to write the password to
|
|
||||||
password_file: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mkpasswd {
|
impl Mkpasswd {
|
||||||
pub fn process(self, _args: &crate::Opts) -> Result<()> {
|
pub fn process(self, args: &crate::Opts) -> Result<()> {
|
||||||
mkpasswd(self)
|
mkpasswd(self, args.password_file.as_deref(), &args.salt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,16 +45,13 @@ pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mkpasswd(
|
pub fn mkpasswd(
|
||||||
Mkpasswd {
|
Mkpasswd { username, password }: Mkpasswd,
|
||||||
username,
|
password_file: Option<&Path>,
|
||||||
password,
|
salt: &str,
|
||||||
salt,
|
|
||||||
password_file,
|
|
||||||
}: Mkpasswd,
|
|
||||||
) -> miette::Result<()> {
|
) -> miette::Result<()> {
|
||||||
let hash = hash_identity(&username, &password, &salt);
|
let hash = hash_identity(&username, &password, salt);
|
||||||
let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref());
|
let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref());
|
||||||
let Some(path) = password_file.as_deref() else {
|
let Some(path) = password_file else {
|
||||||
println!("{encoded}");
|
println!("{encoded}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
136
src/records.rs
136
src/records.rs
|
@ -1,9 +1,52 @@
|
||||||
//! Deal with the DNS records
|
//! Deal with the DNS records
|
||||||
|
|
||||||
use miette::{ensure, miette, LabeledSpan, Result};
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn validate_record_str(record: &str) -> Result<()> {
|
use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result};
|
||||||
validate_line(0, record).map_err(|err| err.with_source_code(String::from(record)))
|
|
||||||
|
/// Loads and verifies the records from a file
|
||||||
|
pub fn load(path: &Path) -> Result<()> {
|
||||||
|
let records = std::fs::read_to_string(path)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
|
||||||
|
|
||||||
|
verify(&records, path)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load records without verifying them
|
||||||
|
pub fn load_no_verify(path: &Path) -> Result<&'static [&'static str]> {
|
||||||
|
let records = std::fs::read_to_string(path)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
|
||||||
|
|
||||||
|
if let Err(err) = verify(&records, path) {
|
||||||
|
tracing::error!("Failed to verify records: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// leak memory: we only do this here and it prevents a bunch of allocations
|
||||||
|
let records: &str = records.leak();
|
||||||
|
let records: Box<[&str]> = records.lines().collect();
|
||||||
|
|
||||||
|
Ok(Box::leak(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that a list of records is valid
|
||||||
|
pub fn verify(data: &str, path: &Path) -> Result<()> {
|
||||||
|
let mut offset = 0usize;
|
||||||
|
for line in data.lines() {
|
||||||
|
validate_line(offset, line).map_err(|err| {
|
||||||
|
err.with_source_code(NamedSource::new(
|
||||||
|
path.display().to_string(),
|
||||||
|
data.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
offset += line.len() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_line(offset: usize, line: &str) -> Result<()> {
|
fn validate_line(offset: usize, line: &str) -> Result<()> {
|
||||||
|
@ -113,7 +156,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use crate::records::validate_record_str;
|
use crate::records::verify;
|
||||||
|
|
||||||
macro_rules! assert_miette_snapshot {
|
macro_rules! assert_miette_snapshot {
|
||||||
($diag:expr) => {{
|
($diag:expr) => {{
|
||||||
|
@ -137,51 +180,104 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn valid_records() -> miette::Result<()> {
|
fn valid_records() -> miette::Result<()> {
|
||||||
for record in [
|
verify(
|
||||||
"example.com.",
|
"\
|
||||||
"example.org.",
|
example.com.\n\
|
||||||
"example.net.",
|
example.org.\n\
|
||||||
"subdomain.example.com.",
|
example.net.\n\
|
||||||
] {
|
subdomain.example.com.\n\
|
||||||
validate_record_str(record)?;
|
",
|
||||||
}
|
std::path::Path::new("test_records_valid"),
|
||||||
Ok(())
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostname_too_long() {
|
fn hostname_too_long() {
|
||||||
let err = validate_record_str("example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.").unwrap_err();
|
let err = verify(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
example.org.\n\
|
||||||
|
example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn not_fqd() {
|
fn not_fqd() {
|
||||||
let err = validate_record_str("example.net").unwrap_err();
|
let err = verify(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
example.org.\n\
|
||||||
|
example.net\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_label() {
|
fn empty_label() {
|
||||||
let err = validate_record_str("name..example.org.").unwrap_err();
|
let err = verify(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name..example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn label_too_long() {
|
fn label_too_long() {
|
||||||
let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err();
|
let err = verify(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_ascii() {
|
fn invalid_ascii() {
|
||||||
let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
|
let err = verify(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name.this-is-not-ascii-ß.example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_octet() {
|
fn invalid_octet() {
|
||||||
let err =
|
let err = verify(
|
||||||
validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err();
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name.this-character:-is-not-allowed.example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert_miette_snapshot!(err);
|
assert_miette_snapshot!(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||||
|
|
||||||
× empty label
|
× empty label
|
||||||
╭────
|
╭─[test_records_invalid:2:6]
|
||||||
1 │ name..example.org.
|
1 │ example.com.
|
||||||
|
2 │ name..example.org.
|
||||||
· ▲
|
· ▲
|
||||||
· ╰── label
|
· ╰── label
|
||||||
|
3 │ example.net.
|
||||||
╰────
|
╰────
|
||||||
help: each label should have at least one character
|
help: each label should have at least one character
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||||
|
|
||||||
× hostname too long (260 octets)
|
× hostname too long (260 octets)
|
||||||
╭────
|
╭─[test_records_invalid:3:1]
|
||||||
1 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
|
2 │ example.org.
|
||||||
|
3 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
|
||||||
· ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
· ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
· ╰── this line
|
· ╰── this line
|
||||||
|
4 │ subdomain.example.com.
|
||||||
╰────
|
╰────
|
||||||
help: fully qualified domain names can be at most 255 characters long
|
help: fully qualified domain names can be at most 255 characters long
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||||
|
|
||||||
× invalid octet: '\xc3'
|
× invalid octet: '\xc3'
|
||||||
╭────
|
╭─[test_records_invalid:2:24]
|
||||||
1 │ name.this-is-not-ascii-ß.example.org.
|
1 │ example.com.
|
||||||
|
2 │ name.this-is-not-ascii-ß.example.org.
|
||||||
· ┬
|
· ┬
|
||||||
· ╰── octet
|
· ╰── octet
|
||||||
|
3 │ example.net.
|
||||||
╰────
|
╰────
|
||||||
help: we only accept ascii characters
|
help: we only accept ascii characters
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||||
|
|
||||||
× invalid octet: ':'
|
× invalid octet: ':'
|
||||||
╭────
|
╭─[test_records_invalid:2:20]
|
||||||
1 │ name.this-character:-is-not-allowed.example.org.
|
1 │ example.com.
|
||||||
|
2 │ name.this-character:-is-not-allowed.example.org.
|
||||||
· ┬
|
· ┬
|
||||||
· ╰── octet
|
· ╰── octet
|
||||||
|
3 │ example.net.
|
||||||
╰────
|
╰────
|
||||||
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]
|
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||||
|
|
||||||
× label too long (78 octets)
|
× label too long (78 octets)
|
||||||
╭────
|
╭─[test_records_invalid:2:6]
|
||||||
1 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
|
1 │ example.com.
|
||||||
|
2 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
|
||||||
· ───────────────────────────────────────┬──────────────────────────────────────
|
· ───────────────────────────────────────┬──────────────────────────────────────
|
||||||
· ╰── label
|
· ╰── label
|
||||||
|
3 │ example.net.
|
||||||
╰────
|
╰────
|
||||||
help: labels should be at most 63 octets
|
help: labels should be at most 63 octets
|
||||||
|
|
|
@ -6,9 +6,11 @@ expression: out
|
||||||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||||
|
|
||||||
× not a fully qualified domain name
|
× not a fully qualified domain name
|
||||||
╭────
|
╭─[test_records_invalid:3:11]
|
||||||
1 │ example.net
|
2 │ example.org.
|
||||||
|
3 │ example.net
|
||||||
· ┬
|
· ┬
|
||||||
· ╰── last character
|
· ╰── last character
|
||||||
|
4 │ subdomain.example.com.
|
||||||
╰────
|
╰────
|
||||||
help: hostname should be a fully qualified domain name (end with a '.')
|
help: hostname should be a fully qualified domain name (end with a '.')
|
||||||
|
|
Loading…
Add table
Reference in a new issue