use std::fmt::{Debug, Display, Formatter};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, ValueEnum};
#[derive(Parser, Debug)]
#[command(version, about = "Roughenough roughtime server")]
pub struct Args {
#[clap(
short = 'b',
long,
value_name = "N",
env = "ROUGHENOUGH_BATCH_SIZE",
default_value_t = 64,
value_parser = clap::value_parser!(u8).range(1..=64)
)]
pub batch_size: u8,
#[clap(
short = 'i',
long,
env = "ROUGHENOUGH_INTERFACE",
default_value = "0.0.0.0"
)]
pub interface: IpAddr,
#[clap(short = 'p', long, env = "ROUGHENOUGH_PORT", default_value = "5319")]
pub port: u16,
#[clap(
short = 'j',
long,
value_name = "N",
env = "ROUGHENOUGH_NUM_THREADS",
default_value_t = default_num_threads(),
value_parser = clap::value_parser!(u16).range(1..)
)]
pub num_threads: u16,
#[clap(
long,
value_name = "N",
env = "ROUGHENOUGH_FIXED_OFFSET",
default_value_t = 0
)]
pub fixed_offset: i16,
#[clap(short, long, conflicts_with = "verbose", default_value_t = false)]
pub quiet: bool,
#[clap(
long,
value_name = "HOURS",
env = "ROUGHENOUGH_ROTATION_INTERVAL",
default_value = "24",
value_parser = clap::value_parser!(u16).range(1..)
)]
pub rotation_interval: u16,
#[clap(
long,
value_name = "SECONDS",
env = "ROUGHENOUGH_METRICS_INTERVAL",
default_value = "60",
value_parser = clap::value_parser!(u64).range(1..=86_400)
)]
pub metrics_interval: u64,
#[clap(
long,
value_name = "PATH",
env = "ROUGHENOUGH_SEED_FILE",
required_unless_present = "insecure_zero_seed",
conflicts_with = "insecure_zero_seed"
)]
pub seed_file: Option<PathBuf>,
#[clap(long, conflicts_with = "seed_file", default_value_t = false)]
pub insecure_zero_seed: bool,
#[clap(
value_enum,
long,
value_name = "TYPE",
env = "ROUGHENOUGH_SEED_BACKEND",
default_value_t = SeedBackendArg::Memory
)]
pub seed_backend: SeedBackendArg,
#[clap(
long,
value_name = "PATH",
env = "ROUGHENOUGH_METRICS_OUTPUT",
help = "Directory where JSON metrics files will be written (e.g., /var/log/roughenough)"
)]
pub metrics_output: Option<String>,
#[clap(
short = 'v',
long,
conflicts_with = "quiet",
action = clap::ArgAction::Count,
help = "Output details about requests and responses; specify multiple times for more detail"
)]
pub verbose: u8,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum SeedBackendArg {
#[value(name = "memory")]
Memory,
#[value(name = "krs")]
Krs,
#[value(name = "ssh-agent")]
SshAgent,
#[value(name = "pkcs11")]
Pkcs11,
}
impl Display for SeedBackendArg {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let value = self.to_possible_value().unwrap();
f.write_str(value.get_name())
}
}
impl Args {
pub fn udp_socket_addr(&self) -> SocketAddr {
SocketAddr::new(self.interface, self.port)
}
pub fn rotation_interval(&self) -> Duration {
Duration::from_secs(self.rotation_interval as u64 * 60 * 60)
}
}
fn default_num_threads() -> u16 {
std::thread::available_parallelism().unwrap().get() as u16
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_args_parse() {
let args = Args::try_parse_from(["roughenough_server", "--insecure-zero-seed"]).unwrap();
assert!(args.insecure_zero_seed);
assert_eq!(args.udp_socket_addr(), "0.0.0.0:5319".parse().unwrap());
}
#[test]
fn ipv4_and_ipv6_interfaces_parse() {
let args =
Args::try_parse_from(["prog", "--insecure-zero-seed", "-i", "127.0.0.1"]).unwrap();
assert_eq!(args.udp_socket_addr(), "127.0.0.1:5319".parse().unwrap());
let args =
Args::try_parse_from(["prog", "--insecure-zero-seed", "-i", "::1", "-p", "9999"])
.unwrap();
assert_eq!(args.udp_socket_addr(), "[::1]:9999".parse().unwrap());
}
#[test]
fn non_ip_interface_is_rejected() {
assert!(Args::try_parse_from(["prog", "--insecure-zero-seed", "-i", "eth0"]).is_err());
assert!(Args::try_parse_from(["prog", "--insecure-zero-seed", "-i", "not-an-ip"]).is_err());
}
#[test]
fn version_output_matches_crate_version() {
let err = Args::try_parse_from(["prog", "--version"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
assert!(err.to_string().contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn rotation_interval_converts_hours_to_seconds() {
let args =
Args::try_parse_from(["prog", "--insecure-zero-seed", "--rotation-interval", "1"])
.unwrap();
assert_eq!(args.rotation_interval(), Duration::from_secs(3600));
let args = Args::try_parse_from([
"prog",
"--insecure-zero-seed",
"--rotation-interval",
"65535",
])
.unwrap();
assert_eq!(args.rotation_interval(), Duration::from_secs(65535 * 3600));
}
#[test]
fn out_of_range_intervals_and_thread_counts_are_rejected() {
for bad in [
["--num-threads", "0"],
["--rotation-interval", "0"],
["--metrics-interval", "0"],
["--metrics-interval", "86401"],
] {
let mut argv = vec!["prog", "--insecure-zero-seed"];
argv.extend(bad);
assert!(Args::try_parse_from(&argv).is_err(), "{bad:?} accepted");
}
let args = Args::try_parse_from([
"prog",
"--insecure-zero-seed",
"--num-threads",
"1",
"--metrics-interval",
"86400",
])
.unwrap();
assert_eq!(args.num_threads, 1);
assert_eq!(args.metrics_interval, 86_400);
}
#[test]
fn seed_file_and_insecure_seed_are_mutually_exclusive() {
assert!(
Args::try_parse_from(["prog", "--seed-file", "/tmp/seed", "--insecure-zero-seed",])
.is_err()
);
}
#[test]
fn direct_seed_argument_is_rejected() {
assert!(Args::try_parse_from(["prog", "--seed", "secret"]).is_err());
}
}