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