feat: add config file to webnsupdate
Move flags to config file, and add more options. Mirror some in the module.
This commit is contained in:
parent
3d660314cf
commit
316f2bf576
17 changed files with 641 additions and 532 deletions
253
src/config.rs
Normal file
253
src/config.rs
Normal file
|
@ -0,0 +1,253 @@
|
|||
use std::{
|
||||
fs::File,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use axum_client_ip::SecureClientIpSource;
|
||||
use miette::{Context, IntoDiagnostic};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, serde::Deserialize, serde::Serialize)]
|
||||
pub enum IpType {
|
||||
#[default]
|
||||
Both,
|
||||
Ipv4Only,
|
||||
Ipv6Only,
|
||||
}
|
||||
|
||||
impl IpType {
|
||||
pub fn valid_for_type(self, ip: IpAddr) -> bool {
|
||||
match self {
|
||||
IpType::Both => true,
|
||||
IpType::Ipv4Only => ip.is_ipv4(),
|
||||
IpType::Ipv6Only => ip.is_ipv6(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IpType::Both => f.write_str("both"),
|
||||
IpType::Ipv4Only => f.write_str("ipv4-only"),
|
||||
IpType::Ipv6Only => f.write_str("ipv6-only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for IpType {
|
||||
type Err = miette::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"both" => Ok(Self::Both),
|
||||
"ipv4-only" => Ok(Self::Ipv4Only),
|
||||
"ipv6-only" => Ok(Self::Ipv6Only),
|
||||
_ => miette::bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Webserver settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Server {
|
||||
/// Ip address and port of the server
|
||||
#[serde(default = "default_address")]
|
||||
pub address: SocketAddr,
|
||||
}
|
||||
|
||||
/// Password settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Password {
|
||||
/// File containing password to match against
|
||||
///
|
||||
/// Should be of the format `username:password` and contain a single password
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password_file: Option<PathBuf>,
|
||||
|
||||
/// Salt to get more unique hashed passwords and prevent table based attacks
|
||||
#[serde(default = "default_salt")]
|
||||
pub salt: Box<str>,
|
||||
}
|
||||
|
||||
/// Records settings
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Records {
|
||||
/// Time To Live (in seconds) to set on the DNS records
|
||||
#[serde(
|
||||
default = "default_ttl",
|
||||
serialize_with = "humantime_ser",
|
||||
deserialize_with = "humantime_de"
|
||||
)]
|
||||
pub ttl: humantime::Duration,
|
||||
|
||||
/// List of domain names for which to update the IP when an update is requested
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub records: Vec<Box<str>>,
|
||||
|
||||
/// If provided, when an IPv6 prefix is provided with an update, this will be used to derive
|
||||
/// the full IPv6 address of the client
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<Ipv6Addr>,
|
||||
|
||||
/// If a client id is provided the ipv6 update will be ignored (only the prefix will be used).
|
||||
/// This domain will point to the ipv6 address instead of the address derived from the client
|
||||
/// id (usually this is the router).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub router_domain: Option<Box<str>>,
|
||||
|
||||
/// Set client IP source
|
||||
///
|
||||
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html>
|
||||
#[serde(default = "default_ip_source")]
|
||||
pub ip_source: SecureClientIpSource,
|
||||
|
||||
/// Set which IPs to allow updating (ipv4, ipv6 or both)
|
||||
#[serde(default = "default_ip_type")]
|
||||
pub ip_type: IpType,
|
||||
|
||||
/// Keyfile `nsupdate` should use
|
||||
///
|
||||
/// If specified, then `webnsupdate` must have read access to the file
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Config {
|
||||
/// Server Configuration
|
||||
#[serde(flatten)]
|
||||
pub server: Server,
|
||||
|
||||
/// Password Configuration
|
||||
#[serde(flatten)]
|
||||
pub password: Password,
|
||||
|
||||
/// Records Configuration
|
||||
#[serde(flatten)]
|
||||
pub records: Records,
|
||||
|
||||
/// The config schema (used for lsp completions)
|
||||
#[serde(default, rename = "$schema", skip_serializing)]
|
||||
pub _schema: serde::de::IgnoredAny,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load the configuration without verifying it
|
||||
pub fn load(path: &std::path::Path) -> miette::Result<Self> {
|
||||
serde_json::from_reader::<File, Self>(
|
||||
File::open(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed open {}", path.display()))?,
|
||||
)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to load configuration from {}", path.display()))
|
||||
}
|
||||
|
||||
/// Ensure only a verified configuration is returned
|
||||
pub fn verified(self) -> miette::Result<Self> {
|
||||
self.verify()?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Verify the configuration
|
||||
pub fn verify(&self) -> Result<(), Invalid> {
|
||||
let mut invalid_records: Vec<miette::Error> = self
|
||||
.records
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|record| crate::records::validate_record_str(record).err())
|
||||
.collect();
|
||||
|
||||
invalid_records.extend(
|
||||
self.records
|
||||
.router_domain
|
||||
.as_ref()
|
||||
.and_then(|domain| crate::records::validate_record_str(domain).err()),
|
||||
);
|
||||
|
||||
let err = Invalid { invalid_records };
|
||||
|
||||
if err.invalid_records.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
|
||||
#[error("the configuration was invalid")]
|
||||
pub struct Invalid {
|
||||
#[related]
|
||||
pub invalid_records: Vec<miette::Error>,
|
||||
}
|
||||
|
||||
// --- Default Values (sadly serde doesn't have a way to specify a constant as a default value) ---
|
||||
|
||||
fn default_ttl() -> humantime::Duration {
|
||||
super::DEFAULT_TTL.into()
|
||||
}
|
||||
|
||||
fn default_salt() -> Box<str> {
|
||||
super::DEFAULT_SALT.into()
|
||||
}
|
||||
|
||||
fn default_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 5353)
|
||||
}
|
||||
|
||||
fn default_ip_source() -> SecureClientIpSource {
|
||||
SecureClientIpSource::RightmostXForwardedFor
|
||||
}
|
||||
|
||||
fn default_ip_type() -> IpType {
|
||||
IpType::Both
|
||||
}
|
||||
|
||||
fn humantime_de<'de, D>(de: D) -> Result<humantime::Duration, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Visitor;
|
||||
impl serde::de::Visitor<'_> for Visitor {
|
||||
type Value = humantime::Duration;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(formatter, "a duration (e.g. 5s)")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
v.parse().map_err(E::custom)
|
||||
}
|
||||
}
|
||||
de.deserialize_str(Visitor)
|
||||
}
|
||||
|
||||
fn humantime_ser<S>(duration: &humantime::Duration, ser: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
ser.serialize_str(&duration.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_values_config_snapshot() {
|
||||
let config: Config = serde_json::from_str("{}").unwrap();
|
||||
insta::assert_json_snapshot!(config, @r#"
|
||||
{
|
||||
"address": "127.0.0.1:5353",
|
||||
"salt": "UpdateMyDNS",
|
||||
"ttl": {
|
||||
"secs": 60,
|
||||
"nanos": 0
|
||||
},
|
||||
"ip_source": "RightmostXForwardedFor",
|
||||
"ip_type": "Both"
|
||||
}
|
||||
"#);
|
||||
}
|
243
src/main.rs
243
src/main.rs
|
@ -10,16 +10,18 @@ use axum::{
|
|||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_client_ip::{SecureClientIp, SecureClientIpSource};
|
||||
use axum_client_ip::SecureClientIp;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_verbosity_flag::Verbosity;
|
||||
use config::Config;
|
||||
use http::StatusCode;
|
||||
use miette::{bail, ensure, Context, IntoDiagnostic, Result};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod nsupdate;
|
||||
mod password;
|
||||
mod records;
|
||||
|
@ -32,120 +34,52 @@ struct Opts {
|
|||
#[command(flatten)]
|
||||
verbosity: Verbosity<clap_verbosity_flag::InfoLevel>,
|
||||
|
||||
/// Ip address of the server
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
address: IpAddr,
|
||||
|
||||
/// Port of the server
|
||||
#[arg(long, default_value_t = 5353)]
|
||||
port: u16,
|
||||
|
||||
/// File containing password to match against
|
||||
///
|
||||
/// Should be of the format `username:password` and contain a single password
|
||||
#[arg(long)]
|
||||
password_file: Option<PathBuf>,
|
||||
|
||||
/// Salt to get more unique hashed passwords and prevent table based attacks
|
||||
#[arg(long, default_value = DEFAULT_SALT)]
|
||||
salt: String,
|
||||
|
||||
/// Time To Live (in seconds) to set on the DNS records
|
||||
#[arg(long, default_value_t = DEFAULT_TTL.as_secs())]
|
||||
ttl: u64,
|
||||
|
||||
/// Data directory
|
||||
#[arg(long, default_value = ".")]
|
||||
#[arg(long, env, default_value = ".")]
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// File containing the records that should be updated when an update request is made
|
||||
///
|
||||
/// There should be one record per line:
|
||||
///
|
||||
/// ```text
|
||||
/// example.com.
|
||||
/// mail.example.com.
|
||||
/// ```
|
||||
#[arg(long)]
|
||||
records: PathBuf,
|
||||
|
||||
/// Keyfile `nsupdate` should use
|
||||
///
|
||||
/// If specified, then `webnsupdate` must have read access to the file
|
||||
#[arg(long)]
|
||||
key_file: Option<PathBuf>,
|
||||
|
||||
/// Allow not setting a password
|
||||
#[arg(long)]
|
||||
insecure: bool,
|
||||
|
||||
/// Set client IP source
|
||||
///
|
||||
/// see: <https://docs.rs/axum-client-ip/latest/axum_client_ip/enum.SecureClientIpSource.html>
|
||||
#[clap(long, default_value = "RightmostXForwardedFor")]
|
||||
ip_source: SecureClientIpSource,
|
||||
#[clap(flatten)]
|
||||
config_or_command: ConfigOrCommand,
|
||||
}
|
||||
|
||||
/// Set which IPs to allow updating
|
||||
#[clap(long, default_value_t = IpType::Both)]
|
||||
ip_type: IpType,
|
||||
#[derive(clap::Args, Debug)]
|
||||
#[group(multiple = false)]
|
||||
struct ConfigOrCommand {
|
||||
/// Path to the configuration file
|
||||
#[arg(long, short)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[clap(subcommand)]
|
||||
subcommand: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
enum IpType {
|
||||
#[default]
|
||||
Both,
|
||||
IPv4Only,
|
||||
IPv6Only,
|
||||
}
|
||||
|
||||
impl IpType {
|
||||
fn valid_for_type(self, ip: IpAddr) -> bool {
|
||||
match self {
|
||||
IpType::Both => true,
|
||||
IpType::IPv4Only => ip.is_ipv4(),
|
||||
IpType::IPv6Only => ip.is_ipv6(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IpType::Both => f.write_str("both"),
|
||||
IpType::IPv4Only => f.write_str("ipv4-only"),
|
||||
IpType::IPv6Only => f.write_str("ipv6-only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for IpType {
|
||||
type Err = miette::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"both" => Ok(Self::Both),
|
||||
"ipv4-only" => Ok(Self::IPv4Only),
|
||||
"ipv6-only" => Ok(Self::IPv6Only),
|
||||
_ => bail!("expected one of 'ipv4-only', 'ipv6-only' or 'both', got '{s}'"),
|
||||
}
|
||||
impl ConfigOrCommand {
|
||||
pub fn take(&mut self) -> (Option<PathBuf>, Option<Cmd>) {
|
||||
(self.config.take(), self.subcommand.take())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Cmd {
|
||||
Mkpasswd(password::Mkpasswd),
|
||||
/// Verify the records file
|
||||
Verify,
|
||||
/// Verify the configuration file
|
||||
Verify {
|
||||
/// Path to the configuration file
|
||||
config: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
pub fn process(self, args: &Opts) -> Result<()> {
|
||||
match self {
|
||||
Cmd::Mkpasswd(mkpasswd) => mkpasswd.process(args),
|
||||
Cmd::Verify => records::load(&args.records).map(drop),
|
||||
Cmd::Verify { config } => config::Config::load(&config) // load config
|
||||
.and_then(Config::verified) // verify config
|
||||
.map(drop), // ignore config data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -168,7 +102,7 @@ struct AppState<'a> {
|
|||
last_ips: std::sync::Arc<tokio::sync::Mutex<SavedIPs>>,
|
||||
|
||||
/// The IP type for which to allow updates
|
||||
ip_type: IpType,
|
||||
ip_type: config::IpType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
@ -211,33 +145,38 @@ impl SavedIPs {
|
|||
}
|
||||
|
||||
impl AppState<'static> {
|
||||
fn from_args(args: &Opts) -> miette::Result<Self> {
|
||||
fn from_args(args: &Opts, config: &config::Config) -> miette::Result<Self> {
|
||||
let Opts {
|
||||
verbosity: _,
|
||||
address: _,
|
||||
port: _,
|
||||
password_file: _,
|
||||
data_dir,
|
||||
key_file,
|
||||
insecure,
|
||||
subcommand: _,
|
||||
records,
|
||||
salt: _,
|
||||
ttl,
|
||||
ip_source: _,
|
||||
ip_type,
|
||||
config_or_command: _,
|
||||
} = args;
|
||||
|
||||
// Set state
|
||||
let ttl = Duration::from_secs(*ttl);
|
||||
let config::Records {
|
||||
ttl,
|
||||
records,
|
||||
client_id: _,
|
||||
router_domain: _,
|
||||
ip_source: _,
|
||||
ip_type,
|
||||
key_file,
|
||||
} = &config.records;
|
||||
|
||||
// Use last registered IP address if available
|
||||
let ip_file = Box::leak(data_dir.join("last-ip.json").into_boxed_path());
|
||||
|
||||
// Leak DNS records
|
||||
let records: &[&str] = &*Vec::leak(
|
||||
records
|
||||
.iter()
|
||||
.map(|record| &*Box::leak(record.clone()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
ttl,
|
||||
// Load DNS records
|
||||
records: records::load_no_verify(records)?,
|
||||
ttl: **ttl,
|
||||
records,
|
||||
// Load keyfile
|
||||
key_file: key_file
|
||||
.as_deref()
|
||||
|
@ -340,34 +279,37 @@ fn main() -> Result<()> {
|
|||
|
||||
debug!("{args:?}");
|
||||
|
||||
// process subcommand
|
||||
if let Some(cmd) = args.subcommand.take() {
|
||||
return cmd.process(&args);
|
||||
}
|
||||
let config = match args.config_or_command.take() {
|
||||
// process subcommand
|
||||
(None, Some(cmd)) => return cmd.process(&args),
|
||||
(Some(path), None) => {
|
||||
let config = config::Config::load(&path)?;
|
||||
if let Err(err) = config.verify() {
|
||||
error!("failed to verify configuration: {err}");
|
||||
}
|
||||
config
|
||||
}
|
||||
(None, None) | (Some(_), Some(_)) => unreachable!(
|
||||
"bad state, one of config or subcommand should be available (clap should enforce this)"
|
||||
),
|
||||
};
|
||||
|
||||
// Initialize state
|
||||
let state = AppState::from_args(&args)?;
|
||||
let state = AppState::from_args(&args, &config)?;
|
||||
|
||||
let Opts {
|
||||
verbosity: _,
|
||||
address: ip,
|
||||
port,
|
||||
password_file,
|
||||
data_dir: _,
|
||||
key_file: _,
|
||||
insecure,
|
||||
subcommand: _,
|
||||
records: _,
|
||||
salt,
|
||||
ttl: _,
|
||||
ip_source,
|
||||
ip_type,
|
||||
config_or_command: _,
|
||||
} = args;
|
||||
|
||||
info!("checking environment");
|
||||
|
||||
// Load password hash
|
||||
let password_hash = password_file
|
||||
let password_hash = config
|
||||
.password
|
||||
.password_file
|
||||
.map(|path| -> miette::Result<_> {
|
||||
let path = path.as_path();
|
||||
let pass = std::fs::read_to_string(path).into_diagnostic()?;
|
||||
|
@ -398,23 +340,26 @@ fn main() -> Result<()> {
|
|||
// Update DNS record with previous IPs (if available)
|
||||
let ips = state.last_ips.lock().await.clone();
|
||||
|
||||
let actions = ips
|
||||
let mut actions = ips
|
||||
.ips()
|
||||
.filter(|ip| ip_type.valid_for_type(*ip))
|
||||
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records));
|
||||
.filter(|ip| config.records.ip_type.valid_for_type(*ip))
|
||||
.flat_map(|ip| nsupdate::Action::from_records(ip, state.ttl, state.records))
|
||||
.peekable();
|
||||
|
||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
error!("nsupdate failed: code {status}");
|
||||
bail!("nsupdate returned with code {status}");
|
||||
if actions.peek().is_some() {
|
||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
error!("nsupdate failed: code {status}");
|
||||
bail!("nsupdate returned with code {status}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to update records with previous IP: {err}");
|
||||
return Err(err)
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to update records with previous IP");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to update records with previous IP: {err}");
|
||||
return Err(err)
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to update records with previous IP");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -422,19 +367,24 @@ fn main() -> Result<()> {
|
|||
let app = Router::new().route("/update", get(update_records));
|
||||
// if a password is provided, validate it
|
||||
let app = if let Some(pass) = password_hash {
|
||||
app.layer(auth::layer(Box::leak(pass), String::leak(salt)))
|
||||
app.layer(auth::layer(
|
||||
Box::leak(pass),
|
||||
Box::leak(config.password.salt),
|
||||
))
|
||||
} else {
|
||||
app
|
||||
}
|
||||
.layer(ip_source.into_extension())
|
||||
.layer(config.records.ip_source.into_extension())
|
||||
.with_state(state);
|
||||
|
||||
let config::Server { address } = config.server;
|
||||
|
||||
// Start services
|
||||
info!("starting listener on {ip}:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::new(ip, port))
|
||||
info!("starting listener on {address}");
|
||||
let listener = tokio::net::TcpListener::bind(address)
|
||||
.await
|
||||
.into_diagnostic()?;
|
||||
info!("listening on {ip}:{port}");
|
||||
info!("listening on {address}");
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
|
@ -573,6 +523,15 @@ async fn trigger_update(
|
|||
state: &AppState<'static>,
|
||||
) -> axum::response::Result<&'static str> {
|
||||
let actions = nsupdate::Action::from_records(ip, state.ttl, state.records);
|
||||
|
||||
if actions.len() == 0 {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Nothing to do (e.g. we are ipv4-only but an ipv6 update was requested)",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
match nsupdate::nsupdate(state.key_file, actions).await {
|
||||
Ok(status) if status.success() => {
|
||||
let ips = {
|
||||
|
|
|
@ -25,7 +25,7 @@ impl<'a> Action<'a> {
|
|||
to: IpAddr,
|
||||
ttl: Duration,
|
||||
records: &'a [&'a str],
|
||||
) -> impl IntoIterator<Item = Self> + 'a {
|
||||
) -> impl IntoIterator<Item = Self> + std::iter::ExactSizeIterator + 'a {
|
||||
records
|
||||
.iter()
|
||||
.map(move |&domain| Action::Reassign { domain, to, ttl })
|
||||
|
@ -91,7 +91,7 @@ fn update_ns_records<'a>(
|
|||
) -> std::io::Result<()> {
|
||||
writeln!(buf, "server 127.0.0.1")?;
|
||||
for action in actions {
|
||||
writeln!(buf, "{action}")?;
|
||||
write!(buf, "{action}")?;
|
||||
}
|
||||
writeln!(buf, "send")?;
|
||||
writeln!(buf, "quit")
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
//! records
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::prelude::*;
|
||||
use miette::{Context, IntoDiagnostic, Result};
|
||||
|
@ -20,11 +20,18 @@ pub struct Mkpasswd {
|
|||
|
||||
/// The password
|
||||
password: String,
|
||||
|
||||
/// An application specific value
|
||||
#[arg(long, default_value = crate::DEFAULT_SALT)]
|
||||
salt: String,
|
||||
|
||||
/// The file to write the password to
|
||||
password_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Mkpasswd {
|
||||
pub fn process(self, args: &crate::Opts) -> Result<()> {
|
||||
mkpasswd(self, args.password_file.as_deref(), &args.salt)
|
||||
pub fn process(self, _args: &crate::Opts) -> Result<()> {
|
||||
mkpasswd(self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,13 +52,16 @@ pub fn hash_identity(username: &str, password: &str, salt: &str) -> Digest {
|
|||
}
|
||||
|
||||
pub fn mkpasswd(
|
||||
Mkpasswd { username, password }: Mkpasswd,
|
||||
password_file: Option<&Path>,
|
||||
salt: &str,
|
||||
Mkpasswd {
|
||||
username,
|
||||
password,
|
||||
salt,
|
||||
password_file,
|
||||
}: Mkpasswd,
|
||||
) -> miette::Result<()> {
|
||||
let hash = hash_identity(&username, &password, salt);
|
||||
let hash = hash_identity(&username, &password, &salt);
|
||||
let encoded = BASE64_URL_SAFE_NO_PAD.encode(hash.as_ref());
|
||||
let Some(path) = password_file else {
|
||||
let Some(path) = password_file.as_deref() else {
|
||||
println!("{encoded}");
|
||||
return Ok(());
|
||||
};
|
||||
|
|
136
src/records.rs
136
src/records.rs
|
@ -1,52 +1,9 @@
|
|||
//! Deal with the DNS records
|
||||
|
||||
use std::path::Path;
|
||||
use miette::{ensure, miette, LabeledSpan, Result};
|
||||
|
||||
use miette::{ensure, miette, Context, IntoDiagnostic, LabeledSpan, NamedSource, Result};
|
||||
|
||||
/// Loads and verifies the records from a file
|
||||
pub fn load(path: &Path) -> Result<()> {
|
||||
let records = std::fs::read_to_string(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
|
||||
|
||||
verify(&records, path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load records without verifying them
|
||||
pub fn load_no_verify(path: &Path) -> Result<&'static [&'static str]> {
|
||||
let records = std::fs::read_to_string(path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("failed to read records from {}", path.display()))?;
|
||||
|
||||
if let Err(err) = verify(&records, path) {
|
||||
tracing::error!("Failed to verify records: {err}");
|
||||
}
|
||||
|
||||
// leak memory: we only do this here and it prevents a bunch of allocations
|
||||
let records: &str = records.leak();
|
||||
let records: Box<[&str]> = records.lines().collect();
|
||||
|
||||
Ok(Box::leak(records))
|
||||
}
|
||||
|
||||
/// Verifies that a list of records is valid
|
||||
pub fn verify(data: &str, path: &Path) -> Result<()> {
|
||||
let mut offset = 0usize;
|
||||
for line in data.lines() {
|
||||
validate_line(offset, line).map_err(|err| {
|
||||
err.with_source_code(NamedSource::new(
|
||||
path.display().to_string(),
|
||||
data.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub fn validate_record_str(record: &str) -> Result<()> {
|
||||
validate_line(0, record).map_err(|err| err.with_source_code(String::from(record)))
|
||||
}
|
||||
|
||||
fn validate_line(offset: usize, line: &str) -> Result<()> {
|
||||
|
@ -156,7 +113,7 @@ fn validate_octet(offset: usize, octet: u8) -> Result<()> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::records::verify;
|
||||
use crate::records::validate_record_str;
|
||||
|
||||
macro_rules! assert_miette_snapshot {
|
||||
($diag:expr) => {{
|
||||
|
@ -180,104 +137,51 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn valid_records() -> miette::Result<()> {
|
||||
verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
example.org.\n\
|
||||
example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_valid"),
|
||||
)
|
||||
for record in [
|
||||
"example.com.",
|
||||
"example.org.",
|
||||
"example.net.",
|
||||
"subdomain.example.com.",
|
||||
] {
|
||||
validate_record_str(record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_too_long() {
|
||||
let err = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
example.org.\n\
|
||||
example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_fqd() {
|
||||
let err = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
example.org.\n\
|
||||
example.net\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("example.net").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_label() {
|
||||
let err = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
name..example.org.\n\
|
||||
example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("name..example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_too_long() {
|
||||
let err = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.\n\
|
||||
example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ascii() {
|
||||
let err = verify(
|
||||
"\
|
||||
example.com.\n\
|
||||
name.this-is-not-ascii-ß.example.org.\n\
|
||||
example.net.\n\
|
||||
subdomain.example.com.\n\
|
||||
",
|
||||
std::path::Path::new("test_records_invalid"),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = validate_record_str("name.this-is-not-ascii-ß.example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_octet() {
|
||||
let err = verify(
|
||||
"\
|
||||
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();
|
||||
let err =
|
||||
validate_record_str("name.this-character:-is-not-allowed.example.org.").unwrap_err();
|
||||
assert_miette_snapshot!(err);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× empty label
|
||||
╭─[test_records_invalid:2:6]
|
||||
1 │ example.com.
|
||||
2 │ name..example.org.
|
||||
╭────
|
||||
1 │ name..example.org.
|
||||
· ▲
|
||||
· ╰── label
|
||||
3 │ example.net.
|
||||
╰────
|
||||
help: each label should have at least one character
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× hostname too long (260 octets)
|
||||
╭─[test_records_invalid:3:1]
|
||||
2 │ example.org.
|
||||
3 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
|
||||
╭────
|
||||
1 │ example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.example.net.
|
||||
· ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
· ╰── this line
|
||||
4 │ subdomain.example.com.
|
||||
╰────
|
||||
help: fully qualified domain names can be at most 255 characters long
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||
|
||||
× invalid octet: '\xc3'
|
||||
╭─[test_records_invalid:2:24]
|
||||
1 │ example.com.
|
||||
2 │ name.this-is-not-ascii-ß.example.org.
|
||||
╭────
|
||||
1 │ name.this-is-not-ascii-ß.example.org.
|
||||
· ┬
|
||||
· ╰── octet
|
||||
3 │ example.net.
|
||||
╰────
|
||||
help: we only accept ascii characters
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Hostname#Syntax\(link)]8;;\
|
||||
|
||||
× invalid octet: ':'
|
||||
╭─[test_records_invalid:2:20]
|
||||
1 │ example.com.
|
||||
2 │ name.this-character:-is-not-allowed.example.org.
|
||||
╭────
|
||||
1 │ name.this-character:-is-not-allowed.example.org.
|
||||
· ┬
|
||||
· ╰── octet
|
||||
3 │ example.net.
|
||||
╰────
|
||||
help: hostnames are only allowed to contain characters in [a-zA-Z0-9_-]
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× label too long (78 octets)
|
||||
╭─[test_records_invalid:2:6]
|
||||
1 │ example.com.
|
||||
2 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
|
||||
╭────
|
||||
1 │ name.an-entremely-long-label-that-should-not-exist-because-it-goes-against-the-spec.example.org.
|
||||
· ───────────────────────────────────────┬──────────────────────────────────────
|
||||
· ╰── label
|
||||
3 │ example.net.
|
||||
╰────
|
||||
help: labels should be at most 63 octets
|
||||
|
|
|
@ -6,11 +6,9 @@ expression: out
|
|||
]8;;https://en.wikipedia.org/wiki/Fully_qualified_domain_name\(link)]8;;\
|
||||
|
||||
× not a fully qualified domain name
|
||||
╭─[test_records_invalid:3:11]
|
||||
2 │ example.org.
|
||||
3 │ example.net
|
||||
╭────
|
||||
1 │ example.net
|
||||
· ┬
|
||||
· ╰── last character
|
||||
4 │ subdomain.example.com.
|
||||
╰────
|
||||
help: hostname should be a fully qualified domain name (end with a '.')
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue