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