webnsupdate: Init at version 0.1.0
This commit is contained in:
commit
43d62fa7d6
10 changed files with 2320 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/result
|
1425
Cargo.lock
generated
Normal file
1425
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
27
Cargo.toml
Normal file
27
Cargo.toml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
description = "An HTTP server using HTTP basic auth to make secure calls to nsupdate"
|
||||||
|
name = "webnsupdate"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7.5"
|
||||||
|
axum-auth = { version = "0.7.0", default-features = false, features = [
|
||||||
|
"auth-basic",
|
||||||
|
] }
|
||||||
|
axum-extra = { version = "0.9.3", features = ["typed-header"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||||
|
headers = "0.4.0"
|
||||||
|
http = "1.1.0"
|
||||||
|
insta = "1.38.0"
|
||||||
|
miette = { version = "7.2.0", features = ["fancy"] }
|
||||||
|
ring = { version = "0.17.8", features = ["std"] }
|
||||||
|
tokio = { version = "1.37.0", features = [
|
||||||
|
"macros",
|
||||||
|
"rt",
|
||||||
|
"process",
|
||||||
|
"io-util",
|
||||||
|
] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
Copyright (c) 2024 Jalil David Salamé Messina <jalil.salame@gmail.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
46
README.md
Normal file
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Web NS update
|
||||||
|
|
||||||
|
A webserver API for `nsupdate`. This is only intended for my usecase, so feel free to take inspiration, but don't expect this to be useful to you.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
> [!Note]
|
||||||
|
> This was made because I needed it. It probably wont fit your usecase.
|
||||||
|
|
||||||
|
Using a flake NixOS configuration add these lines:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.webnsupdate.url = "github:jalil-salame/webnsupdate";
|
||||||
|
# inputs.webnsupdate.inputs.nixpkgs.follows = "nixpkgs"; # deduplicate nixpkgs
|
||||||
|
|
||||||
|
# ...
|
||||||
|
outputs = {
|
||||||
|
nixpkgs,
|
||||||
|
webnsupdate,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
# ...
|
||||||
|
nixosConfigurations.hostname = let
|
||||||
|
system = "...";
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
# IMPORTANT -----------v
|
||||||
|
overlays = [webnsupdate.overlays.default];
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
inherit system pkgs;
|
||||||
|
modules = [
|
||||||
|
webnsupdate.nixosModules.default
|
||||||
|
{
|
||||||
|
services.webnsupdate = {
|
||||||
|
enable = true;
|
||||||
|
# ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
# ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
24
default.nix
Normal file
24
default.nix
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
rustPlatform,
|
||||||
|
}: let
|
||||||
|
readToml = path: builtins.fromTOML (builtins.readFile path);
|
||||||
|
cargoToml = readToml ./Cargo.toml;
|
||||||
|
pname = cargoToml.package.name;
|
||||||
|
inherit (cargoToml.package) version description;
|
||||||
|
in
|
||||||
|
rustPlatform.buildRustPackage {
|
||||||
|
inherit pname version;
|
||||||
|
src = builtins.path {
|
||||||
|
path = ./.;
|
||||||
|
name = "${pname}-source";
|
||||||
|
};
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
useNextest = true;
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
inherit description;
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
homepage = "https://github.com/jalil-salame/webnsupdate";
|
||||||
|
};
|
||||||
|
}
|
26
flake.lock
Normal file
26
flake.lock
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1714906307,
|
||||||
|
"narHash": "sha256-UlRZtrCnhPFSJlDQE7M0eyhgvuuHBTe1eJ9N9AQlJQ0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "25865a40d14b3f9cf19f19b924e2ab4069b09588",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
43
flake.nix
Normal file
43
flake.nix
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
description = "An http server that calls nsupdate internally";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-darwin" "x86_64-darwin" "aarch64-linux"];
|
||||||
|
forEachSupportedSystem = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems (system:
|
||||||
|
f {
|
||||||
|
inherit system;
|
||||||
|
pkgs = import nixpkgs {inherit system;};
|
||||||
|
});
|
||||||
|
in {
|
||||||
|
formatter = forEachSupportedSystem ({pkgs, ...}: pkgs.alejandra);
|
||||||
|
|
||||||
|
# checks = forEachSupportedSystem ({pkgs, ...}: {
|
||||||
|
# module = pkgs.testers.runNixOSTest {
|
||||||
|
# name = "webnsupdate module test";
|
||||||
|
# nodes.testMachine = {imports = [self.nixosModules.default];};
|
||||||
|
# };
|
||||||
|
# });
|
||||||
|
|
||||||
|
packages = forEachSupportedSystem ({pkgs, ...}: {
|
||||||
|
default = pkgs.callPackage ./default.nix {};
|
||||||
|
});
|
||||||
|
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
webnsupdate = final.callPackage ./default.nix {};
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosModules.default = ./module.nix;
|
||||||
|
|
||||||
|
devShells = forEachSupportedSystem ({pkgs, ...}: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [pkgs.cargo-insta];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
159
module.nix
Normal file
159
module.nix
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
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;
|
||||||
|
cmd = lib.concatStringsSep " " ([lib.getExe pkgs.websnupdate]
|
||||||
|
++ 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)
|
||||||
|
]);
|
||||||
|
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";
|
||||||
|
startLimitIntervalSec = 60;
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = cmd;
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = "10s";
|
||||||
|
# User and group
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
# Runtime directory and mode
|
||||||
|
RuntimeDirectory = "websnupdate";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
549
src/main.rs
Normal file
549
src/main.rs
Normal file
|
@ -0,0 +1,549 @@
|
||||||
|
use std::{
|
||||||
|
ffi::OsStr,
|
||||||
|
io::Write,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
os::unix::fs::OpenOptionsExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{ExitStatus, Stdio},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{ConnectInfo, State},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use axum_auth::AuthBasic;
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
use http::StatusCode;
|
||||||
|
use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result};
|
||||||
|
use ring::digest::Digest;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tracing::{info, level_filters::LevelFilter, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
const DEFAULT_TTL: Duration = Duration::from_secs(60);
|
||||||
|
const DEFAULT_SALT: &str = "UpdateMyDNS";
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct Opts {
|
||||||
|
/// 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,
|
||||||
|
/// 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 when the server is exposed to the network
|
||||||
|
#[arg(long)]
|
||||||
|
insecure: bool,
|
||||||
|
#[clap(subcommand)]
|
||||||
|
subcommand: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
struct Mkpasswd {
|
||||||
|
/// The username
|
||||||
|
username: String,
|
||||||
|
/// The password
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Create a password file
|
||||||
|
///
|
||||||
|
/// If `--password-file` is provided, the password is written to that file
|
||||||
|
Mkpasswd(Mkpasswd),
|
||||||
|
/// Verify the records file
|
||||||
|
Verify,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState<'a> {
|
||||||
|
/// TTL set on the Zonefile
|
||||||
|
ttl: Duration,
|
||||||
|
/// Salt added to the password
|
||||||
|
salt: &'a str,
|
||||||
|
/// The IN A/AAAA records that should have their IPs updated
|
||||||
|
records: &'a [&'a str],
|
||||||
|
/// The TSIG key file
|
||||||
|
key_file: Option<&'a Path>,
|
||||||
|
/// The password hash
|
||||||
|
password_hash: Option<&'a [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
miette::set_panic_hook();
|
||||||
|
let Opts {
|
||||||
|
address: ip,
|
||||||
|
port,
|
||||||
|
password_file,
|
||||||
|
key_file,
|
||||||
|
insecure,
|
||||||
|
subcommand,
|
||||||
|
records,
|
||||||
|
salt,
|
||||||
|
ttl,
|
||||||
|
} = Opts::parse();
|
||||||
|
let subscriber = tracing_subscriber::FmtSubscriber::builder()
|
||||||
|
.without_time()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::WARN.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err("setting global tracing subscriber")?;
|
||||||
|
match subcommand {
|
||||||
|
Some(Cmd::Mkpasswd(args)) => return mkpasswd(args, password_file.as_deref(), &salt),
|
||||||
|
Some(Cmd::Verify) => {
|
||||||
|
let data = std::fs::read_to_string(&records)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(|| format!("trying to read {}", records.display()))?;
|
||||||
|
return verify_records(&data, &records);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
info!("checking environment");
|
||||||
|
// Set state
|
||||||
|
let ttl = Duration::from_secs(ttl);
|
||||||
|
let mut state = AppState {
|
||||||
|
ttl,
|
||||||
|
salt: salt.leak(),
|
||||||
|
records: &[],
|
||||||
|
key_file: None,
|
||||||
|
password_hash: None,
|
||||||
|
};
|
||||||
|
if let Some(password_file) = password_file {
|
||||||
|
let pass = std::fs::read(password_file).into_diagnostic()?;
|
||||||
|
state.password_hash = Some(pass.leak());
|
||||||
|
} else {
|
||||||
|
ensure!(insecure, "a password must be used");
|
||||||
|
}
|
||||||
|
if let Some(key_file) = key_file {
|
||||||
|
let path = key_file.as_path();
|
||||||
|
std::fs::File::open(path)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(|| format!("{} is not readable by the current user", path.display()))?;
|
||||||
|
state.key_file = Some(Box::leak(key_file.into_boxed_path()));
|
||||||
|
} else {
|
||||||
|
ensure!(insecure, "a key file must be used");
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(&records)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(|| format!("loading records from {}", records.display()))?;
|
||||||
|
if let Err(err) = verify_records(&data, &records) {
|
||||||
|
warn!("invalid records found: {err}");
|
||||||
|
}
|
||||||
|
state.records = data
|
||||||
|
.lines()
|
||||||
|
.map(|s| &*s.to_string().leak())
|
||||||
|
.collect::<Vec<&'static str>>()
|
||||||
|
.leak();
|
||||||
|
// Start services
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/update", get(update_records))
|
||||||
|
.with_state(state);
|
||||||
|
info!("starting listener on {ip}:{port}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port))
|
||||||
|
.await
|
||||||
|
.into_diagnostic()?;
|
||||||
|
info!("listening on {ip}:{port}");
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.into_diagnostic()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state), level = "trace", ret(level = "warn"))]
|
||||||
|
async fn update_records(
|
||||||
|
State(state): State<AppState<'static>>,
|
||||||
|
AuthBasic((username, pass)): AuthBasic,
|
||||||
|
ConnectInfo(client): ConnectInfo<SocketAddr>,
|
||||||
|
) -> axum::response::Result<&'static str> {
|
||||||
|
let Some(pass) = pass else {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, Json::from("no password provided")).into());
|
||||||
|
};
|
||||||
|
if let Some(stored_pass) = state.password_hash {
|
||||||
|
let password = pass.trim().to_string();
|
||||||
|
|
||||||
|
if hash_identity(&username, &password, state.salt).as_ref() != stored_pass {
|
||||||
|
warn!("rejected update from {username}@{client}");
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "invalid identity").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ip = client.ip();
|
||||||
|
match nsupdate(ip, state.ttl, state.key_file, state.records).await {
|
||||||
|
Ok(status) => {
|
||||||
|
if status.success() {
|
||||||
|
Ok("successful update")
|
||||||
|
} else {
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"nsupdate failed, check server logs",
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("failed to update records: {error}"),
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn nsupdate(
|
||||||
|
ip: IpAddr,
|
||||||
|
ttl: Duration,
|
||||||
|
key_file: Option<&Path>,
|
||||||
|
records: &[&str],
|
||||||
|
) -> std::io::Result<ExitStatus> {
|
||||||
|
let mut cmd = tokio::process::Command::new("nsupdate");
|
||||||
|
if let Some(key_file) = key_file {
|
||||||
|
cmd.args([OsStr::new("-k"), key_file.as_os_str()]);
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::piped());
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let mut stdin = child.stdin.take().expect("stdin not present");
|
||||||
|
stdin
|
||||||
|
.write_all(update_ns_records(ip, ttl, records).as_bytes())
|
||||||
|
.await?;
|
||||||
|
child.wait().await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_ns_records(ip: IpAddr, ttl: Duration, records: &[&str]) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let ttl_s: u64 = ttl.as_secs();
|
||||||
|
|
||||||
|
let rec_type = match ip {
|
||||||
|
IpAddr::V4(_) => "A",
|
||||||
|
IpAddr::V6(_) => "AAAA",
|
||||||
|
};
|
||||||
|
let mut cmds = String::from("server 127.0.0.1\n");
|
||||||
|
for &record in records {
|
||||||
|
writeln!(cmds, "update delete {record} {ttl_s} IN {rec_type}").unwrap();
|
||||||
|
writeln!(cmds, "update add {record} {ttl_s} IN {rec_type} {ip}").unwrap();
|
||||||
|
}
|
||||||
|
writeln!(cmds, "send").unwrap();
|
||||||
|
cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_identity(username: &str, password: &str, salt: &str) -> Digest {
|
||||||
|
let mut data = Vec::with_capacity(username.len() + password.len() + salt.len() + 1);
|
||||||
|
write!(data, "{username}:{password}{salt}").unwrap();
|
||||||
|
ring::digest::digest(&ring::digest::SHA256, &data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkpasswd(
|
||||||
|
Mkpasswd { username, password }: Mkpasswd,
|
||||||
|
password_file: Option<&Path>,
|
||||||
|
salt: &str,
|
||||||
|
) -> miette::Result<()> {
|
||||||
|
let hash = hash_identity(&username, &password, salt);
|
||||||
|
let encoded = URL_SAFE_NO_PAD.encode(hash.as_ref());
|
||||||
|
let Some(path) = password_file else {
|
||||||
|
println!("{encoded}");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let err = || format!("trying to save password hash to {}", path.display());
|
||||||
|
std::fs::File::options()
|
||||||
|
.mode(0o600)
|
||||||
|
.create_new(true)
|
||||||
|
.open(path)
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(err)?
|
||||||
|
.write_all(encoded.as_bytes())
|
||||||
|
.into_diagnostic()
|
||||||
|
.wrap_err_with(err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_records(data: &str, path: &Path) -> miette::Result<()> {
|
||||||
|
let source = || NamedSource::new(path.display().to_string(), data.to_string());
|
||||||
|
let mut byte_offset = 0usize;
|
||||||
|
for line in data.lines() {
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
line.len() <= 255,
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("this line".to_string()),
|
||||||
|
byte_offset,
|
||||||
|
line.len(),
|
||||||
|
)],
|
||||||
|
help = "fully qualified domain names can be at most 255 characters long",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
|
||||||
|
"hostname too long ({} octets)",
|
||||||
|
line.len(),
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
line.ends_with('.'),
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("last character".to_string()),
|
||||||
|
byte_offset + line.len() - 1,
|
||||||
|
1,
|
||||||
|
)],
|
||||||
|
help = "hostname should be a fully qualified domain name (end with a '.')",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
|
||||||
|
"not a fully qualified domain name"
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
let mut local_offset = 0usize;
|
||||||
|
for label in line.strip_suffix('.').unwrap_or(line).split('.') {
|
||||||
|
ensure!(
|
||||||
|
!label.is_empty(),
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("label".to_string()),
|
||||||
|
byte_offset + local_offset,
|
||||||
|
label.len(),
|
||||||
|
)],
|
||||||
|
help = "each label should have at least one character",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
|
||||||
|
"empty label",
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
label.len() <= 63,
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("label".to_string()),
|
||||||
|
byte_offset + local_offset,
|
||||||
|
label.len(),
|
||||||
|
)],
|
||||||
|
help = "labels should be at most 63 octets",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Fully_qualified_domain_name",
|
||||||
|
"label too long ({} octets)",
|
||||||
|
label.len(),
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
for (offset, octet) in label.bytes().enumerate() {
|
||||||
|
ensure!(
|
||||||
|
octet.is_ascii(),
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("octet".to_string()),
|
||||||
|
byte_offset + local_offset + offset,
|
||||||
|
1,
|
||||||
|
)],
|
||||||
|
help = "we only accept ascii characters",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Hostname#Syntax",
|
||||||
|
"'{}' is not ascii",
|
||||||
|
octet.escape_ascii(),
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
octet.is_ascii_alphanumeric() || octet == b'-' || octet == b'_',
|
||||||
|
miette!(
|
||||||
|
labels = [LabeledSpan::new(
|
||||||
|
Some("octet".to_string()),
|
||||||
|
byte_offset + local_offset + offset,
|
||||||
|
1,
|
||||||
|
)],
|
||||||
|
help = "hostnames are only allowed to contain characters in [a-zA-Z0-9_-]",
|
||||||
|
url = "https://en.wikipedia.org/wiki/Hostname#Syntax",
|
||||||
|
"invalid octet: '{}'",
|
||||||
|
octet.escape_ascii(),
|
||||||
|
)
|
||||||
|
.with_source_code(source())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
local_offset += label.len() + 1;
|
||||||
|
}
|
||||||
|
byte_offset += line.len() + 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use insta::assert_snapshot;
|
||||||
|
|
||||||
|
use crate::{update_ns_records, verify_records, DEFAULT_TTL};
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
fn expected_update_string_A() {
|
||||||
|
assert_snapshot!(update_ns_records(
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
DEFAULT_TTL,
|
||||||
|
&["example.com.", "example.org.", "example.net."],
|
||||||
|
), @r###"
|
||||||
|
server 127.0.0.1
|
||||||
|
update delete example.com. 60 IN A
|
||||||
|
update add example.com. 60 IN A 127.0.0.1
|
||||||
|
update delete example.org. 60 IN A
|
||||||
|
update add example.org. 60 IN A 127.0.0.1
|
||||||
|
update delete example.net. 60 IN A
|
||||||
|
update add example.net. 60 IN A 127.0.0.1
|
||||||
|
send
|
||||||
|
"###);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
fn expected_update_string_AAAA() {
|
||||||
|
assert_snapshot!(update_ns_records(
|
||||||
|
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||||
|
DEFAULT_TTL,
|
||||||
|
&["example.com.", "example.org.", "example.net."],
|
||||||
|
), @r###"
|
||||||
|
server 127.0.0.1
|
||||||
|
update delete example.com. 60 IN AAAA
|
||||||
|
update add example.com. 60 IN AAAA ::1
|
||||||
|
update delete example.org. 60 IN AAAA
|
||||||
|
update add example.org. 60 IN AAAA ::1
|
||||||
|
update delete example.net. 60 IN AAAA
|
||||||
|
update add example.net. 60 IN AAAA ::1
|
||||||
|
send
|
||||||
|
"###);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_records() -> miette::Result<()> {
|
||||||
|
verify_records(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_valid"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_too_long() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
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_snapshot!(err, @"hostname too long (260 octets)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_fqd() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
example.org.\n\
|
||||||
|
example.net\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_snapshot!(err, @"not a fully qualified domain name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_label() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name..example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_snapshot!(err, @"empty label");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_too_long() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
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_snapshot!(err, @"label too long (78 octets)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ascii() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
example.com.\n\
|
||||||
|
name.this-is-not-aßcii.example.org.\n\
|
||||||
|
example.net.\n\
|
||||||
|
subdomain.example.com.\n\
|
||||||
|
",
|
||||||
|
std::path::Path::new("test_records_invalid"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_snapshot!(err, @r###"'\xc3' is not ascii"###);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_octet() {
|
||||||
|
let err = verify_records(
|
||||||
|
"\
|
||||||
|
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_snapshot!(err, @"invalid octet: ':'");
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue