feat: Initial functionality
This commit is contained in:
commit
87e9eed8ab
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
target/
|
2783
Cargo.lock
generated
Normal file
2783
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "madd"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.4"
|
||||
base64ct = { version = "1.8.0", features = ["std", "alloc"] }
|
||||
clap = { version = "4.5.43", features = ["derive"] }
|
||||
dns-update = "0.1.5"
|
||||
env_logger = "0.11.8"
|
||||
ipnet = { version = "2.11.0", features = ["serde"] }
|
||||
log = "0.4.27"
|
||||
rand = "0.9.2"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
ssh-key = { version = "0.6.7", features = [
|
||||
"serde",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"crypto",
|
||||
] }
|
||||
tokio = { version = "1.47.1", features = ["full"] }
|
||||
toml = "0.9.5"
|
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Machine Authenticated Dynamic DNS
|
||||
|
||||
Machines can authenticate themselves to MADD using their SSH host key and a challenge response system. When authenticated the MADD server updates their hostname in a DNS server supporting RFC2316 Dynamic DNS updates.
|
30
client.sh
Executable file
30
client.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
private_key="${MADD_PRIV_KEY:-"/etc/ssh/ssh_host_ed25519_key"}"
|
||||
public_key="${MADD_PUB_KEY:-"$private_key.pub"}"
|
||||
|
||||
hostname="${MADD_HOSTNAME:-"$(hostname -s)"}"
|
||||
requested_ip=$MADD_IP # TODO: Obtain IP automatically
|
||||
|
||||
endpoint=$MADD_ENDPOINT
|
||||
|
||||
echo "(MADD) Updating DNS..."
|
||||
echo
|
||||
echo " Endpoint: $endpoint"
|
||||
echo "Priv. key: $private_key"
|
||||
echo " Pub. key: $public_key"
|
||||
echo " Hostname: $hostname"
|
||||
echo " IP: $requested_ip"
|
||||
echo
|
||||
|
||||
# Generate the request and receive the identifier to sign
|
||||
identifier=$(curl "$endpoint/request/$hostname/$requested_ip" --fail-with-body --data-binary @$public_key 2>/dev/null)
|
||||
|
||||
# Sign the request using the SSH key
|
||||
signed=$(echo -n "$identifier $hostname $requested_ip" | ssh-keygen -Y sign -f $private_key -n madd 2>/dev/null)
|
||||
|
||||
# Escape slashes in the identifier
|
||||
identifier_escaped=$(sed "s|/|%2F|g" <<< "$identifier")
|
||||
|
||||
# Submit the signed request
|
||||
curl "$endpoint/signed/$identifier_escaped" --fail-with-body --data "$signed"
|
22
madd.toml
Normal file
22
madd.toml
Normal file
@ -0,0 +1,22 @@
|
||||
# Socket address to bind the MADD server to
|
||||
bind = "127.0.0.1:3000"
|
||||
|
||||
# DNS zone under which the hosts are registered
|
||||
zone = "lab.example.com"
|
||||
|
||||
# List of subnets where hosts can register hostnames to
|
||||
networks = ["10.0.0.0/8"]
|
||||
|
||||
# Maximum number of self-registrations allowed per host key
|
||||
registration_limit = 1
|
||||
|
||||
# DNS server to use for registration, must support RFC 2136
|
||||
dns_server = "127.0.0.1:53"
|
||||
|
||||
# TSIG key configuration for DNS updates
|
||||
tsig_key_name = "madd"
|
||||
tsig_key_file = "/etc/madd/madd.tsig"
|
||||
tsig_algorithm = "hmac-sha256"
|
||||
|
||||
# Directory to store data files, must be writable by the user running MADD
|
||||
data_dir = "/var/lib/madd"
|
190
src/api.rs
Normal file
190
src/api.rs
Normal file
@ -0,0 +1,190 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
};
|
||||
use base64ct::{Base64, Encoding};
|
||||
use log::{info, warn};
|
||||
use ssh_key::{PublicKey, SshSig};
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Config,
|
||||
dns::{Command, CreateRequest, DNSRequest, Identifier, RequestError, SignRequest},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
cmd_tx: mpsc::Sender<Command>,
|
||||
}
|
||||
|
||||
pub async fn start_server(tx: mpsc::Sender<Command>, config: &Config) -> JoinHandle<()> {
|
||||
let state = ServerState { cmd_tx: tx };
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/request/{hostname}/{ip_addr}", post(create_request))
|
||||
.route("/signed/{identifier}", post(signed_request))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(config.bind).await.unwrap();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
match axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.into_future()
|
||||
.await
|
||||
{
|
||||
Ok(_) => todo!(),
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
});
|
||||
|
||||
info!("MADD running on http://{}", config.bind);
|
||||
|
||||
join_handle
|
||||
}
|
||||
|
||||
async fn create_request(
|
||||
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
|
||||
Path((hostname, requested_ip)): Path<(String, Ipv4Addr)>,
|
||||
State(state): State<ServerState>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let host_ip = match connect_info.ip() {
|
||||
IpAddr::V4(ipv4_addr) => ipv4_addr,
|
||||
IpAddr::V6(ipv6_addr) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Access from ipv6 address {ipv6_addr} not allowed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse SSH public key
|
||||
let ssh_key = match PublicKey::from_openssh(&body) {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse public key submitted by client {connect_info}: {e}");
|
||||
return (StatusCode::BAD_REQUEST, String::from("Invalid public key"));
|
||||
}
|
||||
};
|
||||
|
||||
// Create request
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let request = Command::CreateRequest(Box::new(CreateRequest {
|
||||
request: DNSRequest {
|
||||
hostname,
|
||||
time: Instant::now(),
|
||||
ssh_key,
|
||||
requested_ip,
|
||||
host_ip,
|
||||
},
|
||||
response_channel: tx,
|
||||
}));
|
||||
let _ = state.cmd_tx.send(request).await;
|
||||
|
||||
let identifier = match rx.await {
|
||||
Ok(res) => res,
|
||||
Err(e) => unreachable!("Oneshot response channel sender was dropped unexpectedly: {e}"),
|
||||
};
|
||||
|
||||
let encoded = identifier.to_base64();
|
||||
|
||||
(StatusCode::OK, encoded)
|
||||
}
|
||||
|
||||
async fn signed_request(
|
||||
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
|
||||
Path(identifier): Path<String>,
|
||||
State(state): State<ServerState>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let host_ip = match connect_info.ip() {
|
||||
IpAddr::V4(ipv4_addr) => ipv4_addr,
|
||||
IpAddr::V6(ipv6_addr) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Access from ipv6 address {ipv6_addr} not allowed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Decode the identifier
|
||||
let mut bytes = [0; 256];
|
||||
match Base64::decode(identifier, &mut bytes) {
|
||||
Ok(_) => (), // TODO: Check for length?
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
let identifier = Identifier(bytes);
|
||||
|
||||
// Read the signed value
|
||||
let signed = match SshSig::from_pem(&body) {
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse ssh signature submitted by client {connect_info}: {e}");
|
||||
return (StatusCode::BAD_REQUEST, String::from("Bad SSH signature"));
|
||||
}
|
||||
};
|
||||
|
||||
// Create request
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let request = Command::SignRequest(Box::new(SignRequest {
|
||||
identifier,
|
||||
signed,
|
||||
time: Instant::now(),
|
||||
host_ip,
|
||||
response_channel: tx,
|
||||
}));
|
||||
|
||||
let _ = state.cmd_tx.send(request).await;
|
||||
|
||||
match rx.await {
|
||||
Ok(res) => match res {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
String::from("Request fulfilled successfully"),
|
||||
),
|
||||
Err(e) => match e {
|
||||
RequestError::HostIpMismatch => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
String::from(
|
||||
"Signed request must be submitted from the same host that created the request",
|
||||
),
|
||||
),
|
||||
RequestError::InvalidSignature => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
String::from("Invalid SSH signature"),
|
||||
),
|
||||
RequestError::RequestExpired => {
|
||||
(StatusCode::FORBIDDEN, String::from("Request expired"))
|
||||
}
|
||||
RequestError::RequestedIpNotAllowed => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
String::from("Requested IP not allowed"),
|
||||
),
|
||||
RequestError::UpdateFailed => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
String::from("Failed to update DNS record"),
|
||||
),
|
||||
RequestError::AlreadyRegistered => (
|
||||
StatusCode::FORBIDDEN,
|
||||
String::from("Hostname already registered by another host"),
|
||||
),
|
||||
RequestError::TooManyRegistrations => (
|
||||
StatusCode::FORBIDDEN,
|
||||
String::from("Registration limit reached"),
|
||||
),
|
||||
},
|
||||
},
|
||||
Err(e) => unreachable!("Oneshot response channel sender was dropped unexpectedly: {e}"),
|
||||
}
|
||||
}
|
472
src/dns.rs
Normal file
472
src/dns.rs
Normal file
@ -0,0 +1,472 @@
|
||||
use std::{
|
||||
collections::HashMap, hash::Hash, net::Ipv4Addr, ops::Deref, str::FromStr, time::Duration,
|
||||
};
|
||||
|
||||
use base64ct::{Base64, Encoding};
|
||||
use dns_update::DnsUpdater;
|
||||
use log::{error, info, warn};
|
||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
use ssh_key::{PublicKey, SshSig};
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::Config;
|
||||
|
||||
// const MAX_HOSTNAME_LENGTH: usize = 15;
|
||||
|
||||
// #[derive(Debug)]
|
||||
// pub struct Hostname(String);
|
||||
// impl Deref for Hostname {
|
||||
// type Target = String;
|
||||
// fn deref(&self) -> &Self::Target {
|
||||
// &self.0
|
||||
// }
|
||||
// }
|
||||
// impl ser::Serialize for Hostname {
|
||||
// fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
// where
|
||||
// S: ser::Serializer,
|
||||
// {
|
||||
// serializer.serialize_str(&self.0)
|
||||
// }
|
||||
// }
|
||||
// impl<'de> de::Deserialize<'de> for Hostname {
|
||||
// fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
// where
|
||||
// D: serde::Deserializer<'de>,
|
||||
// {
|
||||
// <String as de::Deserialize>::deserialize(deserializer).and_then(|inner| {
|
||||
// if inner.len() > MAX_HOSTNAME_LENGTH {
|
||||
// Err(de::Error::invalid_length(
|
||||
// inner.len(),
|
||||
// &"a shorter hostname",
|
||||
// ))
|
||||
// } else {
|
||||
// Ok(Self(inner))
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DnsAddress(dns_update::providers::rfc2136::DnsAddress);
|
||||
impl Deref for DnsAddress {
|
||||
type Target = dns_update::providers::rfc2136::DnsAddress;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for DnsAddress {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
<String as de::Deserialize>::deserialize(deserializer).and_then(|inner| {
|
||||
match dns_update::providers::rfc2136::DnsAddress::try_from(&inner) {
|
||||
Ok(addr) => Ok(DnsAddress(addr)),
|
||||
Err(_) => Err(de::Error::custom(format!("Invalid DNS address: {inner}"))),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Registrations(HashMap<String, Registration>);
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Registration {
|
||||
pub ip: Ipv4Addr,
|
||||
pub public_key: PublicKey,
|
||||
}
|
||||
|
||||
pub struct TsigAlgorithm(dns_update::TsigAlgorithm);
|
||||
impl Deref for TsigAlgorithm {
|
||||
type Target = dns_update::TsigAlgorithm;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for TsigAlgorithm {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: de::Deserializer<'de>,
|
||||
{
|
||||
<String as de::Deserialize>::deserialize(deserializer).and_then(|inner| {
|
||||
match dns_update::TsigAlgorithm::from_str(&inner) {
|
||||
Ok(algorithm) => Ok(TsigAlgorithm(algorithm)),
|
||||
Err(_) => Err(de::Error::unknown_variant(
|
||||
&inner,
|
||||
&[
|
||||
"hmac-md5",
|
||||
"gss",
|
||||
"hmac-sha1",
|
||||
"hmac-sha224",
|
||||
"hmac-sha256",
|
||||
"hmac-sha256-128",
|
||||
"hmac-sha384",
|
||||
"hmac-sha384-192",
|
||||
"hmac-sha512",
|
||||
"hmac-sha512-256",
|
||||
],
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for TsigAlgorithm {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let algorithm_name = match self.0 {
|
||||
dns_update::TsigAlgorithm::HmacMd5 => "hmac-md5",
|
||||
dns_update::TsigAlgorithm::Gss => "gss",
|
||||
dns_update::TsigAlgorithm::HmacSha1 => "hmac-sha1",
|
||||
dns_update::TsigAlgorithm::HmacSha224 => "hmac-sha224",
|
||||
dns_update::TsigAlgorithm::HmacSha256 => "hmac-sha256",
|
||||
dns_update::TsigAlgorithm::HmacSha256_128 => "hmac-sha256-128",
|
||||
dns_update::TsigAlgorithm::HmacSha384 => "hmac-sha384",
|
||||
dns_update::TsigAlgorithm::HmacSha384_192 => "hmac-sha384-192",
|
||||
dns_update::TsigAlgorithm::HmacSha512 => "hmac-sha512",
|
||||
dns_update::TsigAlgorithm::HmacSha512_256 => "hmac-sha512-256",
|
||||
};
|
||||
write!(f, "{algorithm_name}")
|
||||
}
|
||||
}
|
||||
impl Clone for TsigAlgorithm {
|
||||
fn clone(&self) -> Self {
|
||||
Self(match self.0 {
|
||||
dns_update::TsigAlgorithm::HmacMd5 => dns_update::TsigAlgorithm::HmacMd5,
|
||||
dns_update::TsigAlgorithm::Gss => dns_update::TsigAlgorithm::Gss,
|
||||
dns_update::TsigAlgorithm::HmacSha1 => dns_update::TsigAlgorithm::HmacSha1,
|
||||
dns_update::TsigAlgorithm::HmacSha224 => dns_update::TsigAlgorithm::HmacSha224,
|
||||
dns_update::TsigAlgorithm::HmacSha256 => dns_update::TsigAlgorithm::HmacSha256,
|
||||
dns_update::TsigAlgorithm::HmacSha256_128 => dns_update::TsigAlgorithm::HmacSha256_128,
|
||||
dns_update::TsigAlgorithm::HmacSha384 => dns_update::TsigAlgorithm::HmacSha384,
|
||||
dns_update::TsigAlgorithm::HmacSha384_192 => dns_update::TsigAlgorithm::HmacSha384_192,
|
||||
dns_update::TsigAlgorithm::HmacSha512 => dns_update::TsigAlgorithm::HmacSha512,
|
||||
dns_update::TsigAlgorithm::HmacSha512_256 => dns_update::TsigAlgorithm::HmacSha512_256,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hash, PartialEq, Eq, Clone)]
|
||||
pub struct Identifier(pub [u8; 256]);
|
||||
impl Deref for Identifier {
|
||||
type Target = [u8; 256];
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Identifier {
|
||||
pub fn to_base64(&self) -> String {
|
||||
Base64::encode_string(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Command {
|
||||
CreateRequest(Box<CreateRequest>),
|
||||
SignRequest(Box<SignRequest>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DNSRequest {
|
||||
pub hostname: String,
|
||||
pub time: Instant,
|
||||
pub ssh_key: PublicKey,
|
||||
// TODO: Handle Ipv6
|
||||
pub requested_ip: Ipv4Addr,
|
||||
pub host_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
pub struct CreateRequest {
|
||||
pub request: DNSRequest,
|
||||
pub response_channel: oneshot::Sender<Identifier>,
|
||||
}
|
||||
|
||||
pub struct SignRequest {
|
||||
pub identifier: Identifier,
|
||||
pub signed: SshSig,
|
||||
pub time: Instant,
|
||||
pub host_ip: Ipv4Addr,
|
||||
pub response_channel: oneshot::Sender<Result<(), RequestError>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RequestError {
|
||||
HostIpMismatch,
|
||||
RequestExpired,
|
||||
InvalidSignature,
|
||||
RequestedIpNotAllowed,
|
||||
UpdateFailed,
|
||||
AlreadyRegistered,
|
||||
TooManyRegistrations,
|
||||
}
|
||||
|
||||
pub async fn start_client(rx: mpsc::Receiver<Command>, config: &Config) -> JoinHandle<()> {
|
||||
tokio::spawn(run_client(rx, config.clone()))
|
||||
}
|
||||
|
||||
struct ClientState {
|
||||
config: Config,
|
||||
requests: HashMap<Identifier, DNSRequest>,
|
||||
dns_updater: DnsUpdater,
|
||||
registrations: Registrations,
|
||||
}
|
||||
|
||||
async fn run_client(rx: mpsc::Receiver<Command>, config: Config) {
|
||||
let mut rx = rx;
|
||||
let requests = HashMap::new();
|
||||
let dns_updater = get_dns_updater(&config);
|
||||
let registrations = init_registrations(&config).await;
|
||||
|
||||
init_dns_registrations(®istrations, &config, &dns_updater).await;
|
||||
|
||||
let mut state = ClientState {
|
||||
config,
|
||||
requests,
|
||||
dns_updater,
|
||||
registrations,
|
||||
};
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(cmd) => handle_cmd(cmd, &mut state).await,
|
||||
None => return,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_registrations(config: &Config) -> Registrations {
|
||||
let path = config.data_dir.join("registrations.toml");
|
||||
if !path.exists() {
|
||||
return Registrations(HashMap::new());
|
||||
}
|
||||
|
||||
let contents = tokio::fs::read_to_string(path).await.unwrap();
|
||||
match toml::from_str(&contents) {
|
||||
Ok(registrations) => registrations,
|
||||
Err(e) => {
|
||||
panic!("Failed to parse registrations file: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_dns_registrations(
|
||||
registrations: &Registrations,
|
||||
config: &Config,
|
||||
updater: &DnsUpdater,
|
||||
) {
|
||||
for (hostname, registration) in registrations.0.iter() {
|
||||
execute_dns_update(hostname, ®istration.ip, config, updater)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_registrations(registrations: &Registrations, config: &Config) {
|
||||
let path = config.data_dir.join("registrations.toml");
|
||||
let contents = toml::to_string(registrations).unwrap();
|
||||
tokio::fs::write(path, contents).await.unwrap();
|
||||
}
|
||||
|
||||
fn get_dns_updater(config: &Config) -> DnsUpdater {
|
||||
let addr = format!("tcp://{}", config.dns_server);
|
||||
let key_name = &config.tsig_key_name;
|
||||
let key = match std::fs::read_to_string(&config.tsig_key_file) {
|
||||
Ok(contents) => Base64::decode_vec(&contents).unwrap(),
|
||||
Err(e) => panic!("Failed to read TSIG key file: {e}"),
|
||||
};
|
||||
let algorithm = config.tsig_algorithm.clone().0;
|
||||
info!("Creating DNS client for {addr}");
|
||||
dns_update::DnsUpdater::new_rfc2136_tsig(addr, key_name, key, algorithm)
|
||||
.expect("Failed to create DNS client")
|
||||
}
|
||||
|
||||
async fn handle_cmd(cmd: Command, state: &mut ClientState) {
|
||||
match cmd {
|
||||
Command::CreateRequest(create_request) => {
|
||||
handle_create_request(*create_request, state).await
|
||||
}
|
||||
Command::SignRequest(sign_request) => handle_sign_request(*sign_request, state).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_create_request(request: CreateRequest, state: &mut ClientState) {
|
||||
let random_value: [u8; 256] = StdRng::from_os_rng().random();
|
||||
let identifier = Identifier(random_value);
|
||||
state
|
||||
.requests
|
||||
.insert(identifier.clone(), request.request.clone());
|
||||
let _ = request.response_channel.send(identifier);
|
||||
info!(
|
||||
"Registered host update request for {} to {} from {}.",
|
||||
&request.request.hostname, &request.request.requested_ip, &request.request.host_ip
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_sign_request(request: SignRequest, state: &mut ClientState) {
|
||||
let dns_request = match state.requests.remove(&request.identifier) {
|
||||
Some(req) => req,
|
||||
None => todo!(),
|
||||
};
|
||||
|
||||
// The request must be submitted and signed by the same host
|
||||
if dns_request.host_ip != request.host_ip {
|
||||
warn!(
|
||||
"Host IP mismatch for signed request for host {}: expected from {}, received from {}",
|
||||
dns_request.hostname, dns_request.host_ip, request.host_ip
|
||||
);
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::HostIpMismatch));
|
||||
return;
|
||||
}
|
||||
|
||||
// The requested IP must be within the allowed networks
|
||||
if !state
|
||||
.config
|
||||
.networks
|
||||
.iter()
|
||||
.any(|net| net.contains(&dns_request.requested_ip))
|
||||
{
|
||||
warn!(
|
||||
"Requested IP {} for host {} is not allowed",
|
||||
dns_request.requested_ip, dns_request.hostname
|
||||
);
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::RequestedIpNotAllowed));
|
||||
return;
|
||||
}
|
||||
|
||||
// The signed request must be submitted within 10 seconds of request creation
|
||||
if request.time - dns_request.time > Duration::from_secs(10) {
|
||||
warn!("Request expired: signed request is older than 10 seconds");
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::RequestExpired));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the SSH signature
|
||||
let original_text = format!(
|
||||
"{} {} {}",
|
||||
request.identifier.to_base64(),
|
||||
dns_request.hostname,
|
||||
dns_request.requested_ip
|
||||
);
|
||||
match dns_request
|
||||
.ssh_key
|
||||
.verify("madd", original_text.as_bytes(), &request.signed)
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to verify SSH signature for host {}: {}",
|
||||
dns_request.hostname, e
|
||||
);
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::InvalidSignature));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check with the registrations if the hostname has not already been registered
|
||||
if let Some(registration) = state.registrations.0.get(&dns_request.hostname)
|
||||
&& registration.public_key.key_data() != dns_request.ssh_key.key_data()
|
||||
{
|
||||
warn!(
|
||||
"Host {} attempted to register {} which has already been registered by a different host",
|
||||
dns_request.host_ip, dns_request.hostname
|
||||
);
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::AlreadyRegistered));
|
||||
return;
|
||||
}
|
||||
|
||||
// Restrict the number of self-registrations per host
|
||||
if !state.registrations.0.contains_key(&dns_request.hostname)
|
||||
&& state
|
||||
.registrations
|
||||
.0
|
||||
.iter()
|
||||
.filter(|(_, registration)| {
|
||||
registration.public_key.key_data() == dns_request.ssh_key.key_data()
|
||||
})
|
||||
.count()
|
||||
>= state.config.registration_limit
|
||||
{
|
||||
warn!(
|
||||
"Host {} attempted to register {} but has already reached the registration limit",
|
||||
dns_request.host_ip, dns_request.hostname
|
||||
);
|
||||
let _ = request
|
||||
.response_channel
|
||||
.send(Err(RequestError::TooManyRegistrations));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the registration
|
||||
state.registrations.0.insert(
|
||||
dns_request.hostname.clone(),
|
||||
Registration {
|
||||
ip: dns_request.requested_ip,
|
||||
public_key: dns_request.ssh_key,
|
||||
},
|
||||
);
|
||||
write_registrations(&state.registrations, &state.config).await;
|
||||
|
||||
// Execute the DNS update request
|
||||
match execute_dns_update(
|
||||
&dns_request.hostname,
|
||||
&dns_request.requested_ip,
|
||||
&state.config,
|
||||
&state.dns_updater,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
let _ = request.response_channel.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = request.response_channel.send(Ok(()));
|
||||
info!(
|
||||
"Executed host update request for {} to {} from {}.",
|
||||
&dns_request.hostname, &dns_request.requested_ip, &dns_request.host_ip
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_dns_update(
|
||||
hostname: &String,
|
||||
ip: &Ipv4Addr,
|
||||
config: &Config,
|
||||
updater: &DnsUpdater,
|
||||
) -> Result<(), RequestError> {
|
||||
let name = &format!("{}.{}", hostname, config.zone);
|
||||
let record = dns_update::DnsRecord::A { content: *ip };
|
||||
let ttl = 60;
|
||||
let origin = &config.zone;
|
||||
match updater
|
||||
.delete(name, origin, dns_update::DnsRecordType::A)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
error!("Failed to delete existing DNS record: {e}");
|
||||
return Err(RequestError::UpdateFailed);
|
||||
}
|
||||
};
|
||||
match updater.create(name, record, ttl, origin).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
error!("Failed to create DNS record: {e}");
|
||||
Err(RequestError::UpdateFailed)
|
||||
}
|
||||
}
|
||||
}
|
83
src/main.rs
Normal file
83
src/main.rs
Normal file
@ -0,0 +1,83 @@
|
||||
use clap::Parser;
|
||||
use dns::TsigAlgorithm;
|
||||
use ipnet::Ipv4Net;
|
||||
use log::{debug, info};
|
||||
use serde::Deserialize;
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
mod api;
|
||||
mod dns;
|
||||
|
||||
/// Machine Authenticated Dynamic DNS (MADD)
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "/etc/madd/madd.toml")]
|
||||
config_file: PathBuf,
|
||||
|
||||
/// Log level (e.g., info, debug, error). Can also be set via the LOG_LEVEL environment variable
|
||||
#[arg(short, long, default_value = "info")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
fn get_args() -> Args {
|
||||
Args::parse()
|
||||
}
|
||||
|
||||
fn init_logging(args: &Args) {
|
||||
let env = env_logger::Env::new()
|
||||
.filter("LOG_LEVEL")
|
||||
.default_filter_or(args.log_level.clone())
|
||||
.write_style("LOG_STYLE");
|
||||
env_logger::init_from_env(env);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct Config {
|
||||
bind: SocketAddr,
|
||||
zone: String,
|
||||
networks: Vec<Ipv4Net>,
|
||||
registration_limit: usize,
|
||||
data_dir: PathBuf,
|
||||
dns_server: SocketAddr,
|
||||
tsig_key_name: String,
|
||||
tsig_key_file: PathBuf,
|
||||
tsig_algorithm: TsigAlgorithm,
|
||||
}
|
||||
|
||||
fn get_config(args: &Args) -> Config {
|
||||
let path = &args.config_file;
|
||||
let file_contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(e) => panic!(
|
||||
"Failed to read config file from \"{}\": {e}",
|
||||
path.display()
|
||||
),
|
||||
};
|
||||
match toml::from_str(&file_contents) {
|
||||
Ok(config) => config,
|
||||
Err(e) => panic!("Failed to parse config file: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Parse command line arguments
|
||||
let args = get_args();
|
||||
|
||||
// Initialize logging
|
||||
init_logging(&args);
|
||||
info!("Starting MADD");
|
||||
|
||||
// Gather configuration
|
||||
let config = get_config(&args);
|
||||
debug!("{config:?}");
|
||||
|
||||
// Start DNS client and API server
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
let handle_client = dns::start_client(rx, &config).await;
|
||||
let handle_server = api::start_server(tx, &config).await;
|
||||
let _ = (handle_client.await.unwrap(), handle_server.await.unwrap());
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user