ready for beta-tests.
This commit is contained in:
@@ -1,27 +1,68 @@
|
||||
use hickory_proto::rr::LowerName;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::model::User;
|
||||
use std::hash::Hash;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(unused)]
|
||||
pub struct Config {
|
||||
pub dns_addr: Option<String>,
|
||||
pub api_port: Option<u16>,
|
||||
pub zone_name: String,
|
||||
pub base_zone: LowerName,
|
||||
#[serde(default)]
|
||||
pub auths: Vec<User>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(config_file: &Option<String>) -> Self {
|
||||
use config::File;
|
||||
let config = config::Config::builder()
|
||||
.add_source(File::with_name("/etc/tiny-dns.toml").required(false))
|
||||
.add_source(File::with_name("config.toml").required(false))
|
||||
.add_source(
|
||||
File::with_name(config_file.as_ref().map_or("config.toml", String::as_str))
|
||||
.required(true),
|
||||
)
|
||||
.build()
|
||||
.expect("Failed to load config");
|
||||
config
|
||||
let c: Self = config
|
||||
.try_deserialize()
|
||||
.expect("Failed to deserialize config")
|
||||
.expect("Failed to deserialize config");
|
||||
if !c.base_zone.is_fqdn() {
|
||||
panic!(
|
||||
"Field `base_zone = {}` isn't a fully qualified domain name (ending with a dot)",
|
||||
c.base_zone
|
||||
);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub fn dns_addr(&self) -> &str {
|
||||
self.dns_addr.as_deref().unwrap_or("[::]:3053")
|
||||
}
|
||||
|
||||
pub fn api_port(&self) -> String {
|
||||
format!("[::]:{}", self.api_port.unwrap_or(3000))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub login: String,
|
||||
pub key: String,
|
||||
pub scope: Vec<LowerName>,
|
||||
pub base_zone: Option<LowerName>,
|
||||
}
|
||||
|
||||
impl Eq for User {}
|
||||
|
||||
impl PartialEq for User {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.login == other.login && self.key == other.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for User {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.login.hash(state);
|
||||
self.key.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
140
src/dns.rs
Normal file
140
src/dns.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::model::DnsRecords;
|
||||
use async_trait::async_trait;
|
||||
use hickory_proto::{
|
||||
ProtoError,
|
||||
op::{Header, MessageType, ResponseCode},
|
||||
rr::{DNSClass, Record, RecordType},
|
||||
};
|
||||
use hickory_server::{
|
||||
ServerFuture,
|
||||
authority::MessageResponseBuilder,
|
||||
server::{Request, RequestHandler, ResponseHandler, ResponseInfo},
|
||||
};
|
||||
use std::{ops::Deref, time::Duration};
|
||||
use tokio::net::{TcpListener, UdpSocket};
|
||||
|
||||
/// Wrap a DnsChallenges to implement [`DnsRequestHandler`].
|
||||
///
|
||||
/// Implementing [`DnsRequestHandler`] tells trust DNS how to use
|
||||
/// our challenges database to answer DNS requests.
|
||||
struct DnsRequestHandler {
|
||||
challenges: DnsRecords,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RequestHandler for DnsRequestHandler {
|
||||
/// Generate a DNS response for a DNS request.
|
||||
async fn handle_request<R: ResponseHandler>(
|
||||
&self,
|
||||
request: &Request,
|
||||
mut response_handle: R,
|
||||
) -> ResponseInfo {
|
||||
let req_message = request.deref();
|
||||
let query = match req_message.queries() {
|
||||
[q] => q,
|
||||
_ => unimplemented!(
|
||||
"Tiny-DNS does not support DNS messages with zero or more than one query."
|
||||
),
|
||||
};
|
||||
let answer_records = match (query.query_class(), query.query_type()) {
|
||||
(DNSClass::IN, RecordType::TXT | RecordType::A | RecordType::AAAA) => {
|
||||
let name = query.original().name();
|
||||
let tokens = &self.challenges.get_tokens(query.name());
|
||||
tracing::debug!("For `{name}` tokens found {} token(s)", tokens.len(),);
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|(rt, _)| *rt == query.query_type())
|
||||
.map(|(_, rdata)| Record::from_rdata(name.clone(), 1, rdata.clone()))
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let mut header = Header::new();
|
||||
header
|
||||
.set_id(req_message.id())
|
||||
.set_message_type(MessageType::Response)
|
||||
.set_op_code(req_message.op_code())
|
||||
.set_authoritative(true)
|
||||
.set_truncated(false)
|
||||
.set_recursion_available(false)
|
||||
.set_recursion_desired(req_message.recursion_desired())
|
||||
.set_authentic_data(false)
|
||||
.set_checking_disabled(req_message.checking_disabled())
|
||||
.set_response_code(ResponseCode::NoError)
|
||||
.set_query_count(1)
|
||||
.set_answer_count(answer_records.len().try_into().unwrap())
|
||||
.set_name_server_count(0)
|
||||
.set_additional_count(0);
|
||||
|
||||
let response = MessageResponseBuilder::from_message_request(req_message).build(
|
||||
header,
|
||||
Box::new(answer_records.iter()) as Box<dyn Iterator<Item = &Record> + Send>,
|
||||
Box::new(None.iter()) as Box<dyn Iterator<Item = &Record> + Send>,
|
||||
Box::new(None.iter()) as Box<dyn Iterator<Item = &Record> + Send>,
|
||||
Box::new(None.iter()) as Box<dyn Iterator<Item = &Record> + Send>,
|
||||
);
|
||||
response_handle.send_response(response).await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level struct and entry point of the module.
|
||||
///
|
||||
/// Creates all sub structs needed to answer DNS-01 challenges
|
||||
/// and add domain-name/tokens pairs to our challenge database.
|
||||
pub struct DnsWorker {
|
||||
server_future: ServerFuture<DnsRequestHandler>,
|
||||
dns_records: DnsRecords,
|
||||
}
|
||||
|
||||
impl DnsWorker {
|
||||
/// Create a new DnsWorker
|
||||
pub async fn new(listening_addr: &str) -> std::io::Result<Self> {
|
||||
let challenges = DnsRecords::new();
|
||||
let mut server_future = ServerFuture::new(DnsRequestHandler {
|
||||
challenges: challenges.clone(),
|
||||
});
|
||||
let error_message = format!(
|
||||
"Cannot bind to `{listening_addr}`: Permission denied.
|
||||
Either run with sudo or grant capability with: setcap 'cap_net_bind_service=+ep' tiny-dns",
|
||||
);
|
||||
let udp_socket = match UdpSocket::bind(&listening_addr).await {
|
||||
Ok(socket) => socket,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
error_message,
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
server_future.register_socket(udp_socket);
|
||||
|
||||
let tcp_listener = match TcpListener::bind(&listening_addr).await {
|
||||
Ok(listener) => listener,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
error_message,
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
server_future.register_listener(tcp_listener, Duration::from_secs(60));
|
||||
|
||||
Ok(Self {
|
||||
server_future,
|
||||
dns_records: challenges,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the DNS server
|
||||
pub async fn serve(mut self) -> std::result::Result<(), ProtoError> {
|
||||
self.server_future.block_until_done().await
|
||||
}
|
||||
|
||||
/// Get a reference to the dns worker's DNS records database.
|
||||
pub const fn dns_records(&self) -> &DnsRecords {
|
||||
&self.dns_records
|
||||
}
|
||||
}
|
||||
76
src/main.rs
76
src/main.rs
@@ -1,39 +1,65 @@
|
||||
use hickory_server::{ServerFuture, authority::Catalog, proto::rr::LowerName};
|
||||
use std::str::FromStr;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
mod config;
|
||||
mod dns;
|
||||
mod model;
|
||||
mod webapi;
|
||||
use config::Config;
|
||||
use model::AppState;
|
||||
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
config_file: Option<String>,
|
||||
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
debug: bool,
|
||||
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
quiet: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
const fn loglevel(&self) -> Level {
|
||||
if self.debug {
|
||||
Level::DEBUG
|
||||
} else if self.quiet {
|
||||
Level::WARN
|
||||
} else {
|
||||
Level::INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = Config::new();
|
||||
let api = webapi::router(&config);
|
||||
let listen = config
|
||||
.api_port
|
||||
.map(|port| format!("[::]:{port}"))
|
||||
.unwrap_or("[::]:3000".to_owned());
|
||||
println!("Starting tiny-dns on: http://{listen}");
|
||||
// run our app with hyper, listening globally on port 3000
|
||||
let api_listener = tokio::net::TcpListener::bind(listen).await.unwrap();
|
||||
let args = Args::parse();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.with_max_level(args.loglevel())
|
||||
.init();
|
||||
let config = Config::new(&args.config_file);
|
||||
|
||||
let dns_listen = &config.dns_addr();
|
||||
let web_api_listen = config.api_port();
|
||||
|
||||
// DNS server
|
||||
let dns_addr = config.dns_addr.unwrap_or("[::]:3053".to_owned());
|
||||
tracing::info!("DNS server running on {}", dns_listen);
|
||||
let dns_worker = dns::DnsWorker::new(dns_listen).await.unwrap();
|
||||
|
||||
let mut catalog = Catalog::new();
|
||||
catalog.upsert(LowerName::from_str("example.com").unwrap(), vec![]);
|
||||
let mut server_future = ServerFuture::new(catalog);
|
||||
let socket = UdpSocket::bind(&dns_addr)
|
||||
.await
|
||||
.expect("Failed to bind DNS socket");
|
||||
server_future.register_socket(socket);
|
||||
println!("DNS server running on {}", dns_addr);
|
||||
// Web API server
|
||||
let api = webapi::router(&config, dns_worker.dns_records().clone());
|
||||
|
||||
tracing::info!("Starting Web API on: http://{web_api_listen}");
|
||||
let http_listener = tokio::net::TcpListener::bind(web_api_listen).await.unwrap();
|
||||
|
||||
let api_handle = axum::serve(http_listener, api);
|
||||
let dns_handle = dns_worker.serve();
|
||||
|
||||
let api_handle = axum::serve(api_listener, api);
|
||||
let dns = server_future.block_until_done();
|
||||
let _ = api_handle.await;
|
||||
let _ = dns.await;
|
||||
let _ = dns_handle.await;
|
||||
}
|
||||
|
||||
366
src/model.rs
366
src/model.rs
@@ -1,196 +1,30 @@
|
||||
use hickory_proto::rr::RecordData;
|
||||
use http::HeaderMap;
|
||||
use maud::{Markup, Render, html};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
hash::Hash,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use crate::config::User;
|
||||
use hickory_proto::rr::LowerName;
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub auths: Arc<Mutex<HashSet<User>>>,
|
||||
pub domains: Arc<Mutex<HashSet<DnsRecord>>>,
|
||||
pub domains: DnsRecords,
|
||||
pub base_zone: LowerName,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn is_allowed(&self, user: &User, domain: impl Into<String>) -> bool {
|
||||
let domain = domain.into();
|
||||
self.auths.lock().is_ok_and(|a| {
|
||||
a.iter().any(|u| {
|
||||
pub fn is_allowed(&self, user: &User, domain: &LowerName) -> bool {
|
||||
self.auths.lock().is_ok_and(|auth| {
|
||||
auth.iter().any(|u| {
|
||||
u.login == user.login
|
||||
&& u.key == user.key
|
||||
&& u.scope.iter().any(|s| domain.contains(s))
|
||||
&& u.scope.iter().any(|d| d.zone_of(domain))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Deserialize, Serialize)]
|
||||
pub enum DnsType {
|
||||
A,
|
||||
AAAA,
|
||||
#[default]
|
||||
TXT,
|
||||
}
|
||||
|
||||
impl Display for DnsType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DnsType::A => write!(f, "A"),
|
||||
DnsType::AAAA => write!(f, "AAAA"),
|
||||
DnsType::TXT => write!(f, "TXT"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JsonDomain {
|
||||
pub subdomain: String,
|
||||
pub rtype: Option<DnsType>,
|
||||
pub rdata: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DnsRecord {
|
||||
domain: String,
|
||||
qname: String,
|
||||
time: u64,
|
||||
rtype: DnsType,
|
||||
rdata: String,
|
||||
}
|
||||
|
||||
impl DnsRecord {
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Split a full domain into subdomain and domain.
|
||||
pub fn split(fulldomain: &str) -> (String, String) {
|
||||
let parts: Vec<&str> = fulldomain.split('.').collect();
|
||||
|
||||
if parts.len() < 2 {
|
||||
("_".to_owned(), fulldomain.to_owned())
|
||||
} else {
|
||||
let l = parts.len() - 2;
|
||||
(parts[0..l].join("."), parts[l..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new domain with a lifetime of one hour.
|
||||
pub fn new_txt(subdomain: String, txt: String) -> Self {
|
||||
let (qname, domain) = Self::split(&subdomain);
|
||||
DnsRecord {
|
||||
domain,
|
||||
qname,
|
||||
time: Self::now() + 3600,
|
||||
rtype: DnsType::TXT,
|
||||
rdata: txt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn age(&self) -> u64 {
|
||||
Self::now() + 3600 - self.time
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DnsRecord {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}: {} {} {}",
|
||||
self.domain, self.qname, self.rtype, self.rdata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DnsRecord {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
div .cell { (self.domain) }
|
||||
div .cell { (self.qname) }
|
||||
div .cell { (self.rtype) }
|
||||
div .cell { (self.rdata) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonDomain> for DnsRecord {
|
||||
fn from(json: JsonDomain) -> Self {
|
||||
let (qname, domain) = Self::split(&json.subdomain);
|
||||
DnsRecord {
|
||||
domain,
|
||||
qname,
|
||||
time: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600,
|
||||
rtype: json.rtype.unwrap_or_default(),
|
||||
rdata: json.rdata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for DnsRecord {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.domain.hash(state);
|
||||
self.qname.hash(state);
|
||||
self.rtype.hash(state);
|
||||
self.rdata.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for DnsRecord {}
|
||||
|
||||
impl PartialEq for DnsRecord {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.domain == other.domain
|
||||
&& self.qname == other.qname
|
||||
&& self.rtype == other.rtype
|
||||
&& self.rdata == other.rdata
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordData for DnsRecord {
|
||||
fn into_rdata(self) -> hickory_proto::rr::RData {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn is_update(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn record_type(&self) -> hickory_proto::rr::RecordType {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn try_borrow(data: &hickory_proto::rr::RData) -> Option<&Self> {
|
||||
todo!()
|
||||
}
|
||||
fn try_from_rdata(data: hickory_proto::rr::RData) -> Result<Self, hickory_proto::rr::RData> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub login: String,
|
||||
pub key: String,
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&HeaderMap> for User {
|
||||
fn from(headers: &HeaderMap) -> Self {
|
||||
pub fn find_user(&self, headers: &HeaderMap) -> Result<User, StatusCode> {
|
||||
let login = headers
|
||||
.get("x-api-user")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -201,40 +35,148 @@ impl From<&HeaderMap> for User {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
User {
|
||||
login,
|
||||
key,
|
||||
scope: Vec::new(),
|
||||
self.auths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|u| u.login == login && u.key == key)
|
||||
.cloned()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JsonUpdate {
|
||||
pub domain: LowerName,
|
||||
#[serde(default = "txt_type")]
|
||||
pub rtype: hickory_proto::rr::RecordType,
|
||||
pub rdata: String,
|
||||
}
|
||||
|
||||
fn txt_type() -> hickory_proto::rr::RecordType {
|
||||
hickory_proto::rr::RecordType::TXT
|
||||
}
|
||||
|
||||
type Rentry = (RecordType, RData);
|
||||
|
||||
/// All the DNS Records for use in DNS and web-API.
|
||||
///
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DnsRecords {
|
||||
tokens: Arc<Mutex<HashMap<Name, Vec<Rentry>>>>,
|
||||
}
|
||||
|
||||
impl DnsRecords {
|
||||
/// Create a new instance of DnsChallenges
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a challenge token to the DNS worker
|
||||
///
|
||||
/// # Arguments:
|
||||
/// - `name`: the domain name being challenged
|
||||
/// - `rtype`: the type of the record being challenged
|
||||
/// - `val`: the value of the TXT, A or AAAA field for that challenge
|
||||
pub fn add_token(&self, name: Name, rtype: RecordType, val: RData) {
|
||||
let mut lock = self.tokens.lock().unwrap();
|
||||
let records = lock.entry(name.to_lowercase()).or_default();
|
||||
match rtype {
|
||||
// allow only one A or AAAA record per domain name
|
||||
RecordType::A | RecordType::AAAA => {
|
||||
for (t, v) in records.iter_mut() {
|
||||
if *t == rtype {
|
||||
tracing::debug!("Changing A/AAAA token `{}` to name: `{name}`", &val);
|
||||
*v = val;
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::debug!("Adding A/AAAA token `{val}` to name: `{name}`");
|
||||
records.push((rtype, val));
|
||||
}
|
||||
// allow multiple unique TXT records
|
||||
RecordType::TXT => {
|
||||
for (t, v) in records.iter_mut() {
|
||||
if *t == rtype && *v == val {
|
||||
tracing::debug!("Ignoring duplicate TXT token `{val}` to name: `{name}`");
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::debug!("Adding TXT token `{val}` to name: `{name}`");
|
||||
records.push((rtype, val));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all challenge tokens associated with a given domain name
|
||||
pub fn get_tokens(&self, name: &Name) -> Vec<Rentry> {
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&name.to_lowercase())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all challenge tokens associated with a given domain name
|
||||
pub fn get_all_tokens(&self) -> Vec<(Name, Rentry)> {
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|(name, vector)| {
|
||||
vector
|
||||
.iter()
|
||||
.map(|token| (name.clone(), (token.clone())))
|
||||
.collect::<Vec<(Name, Rentry)>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove a A / AAAA record from the DNS worker
|
||||
///
|
||||
/// # Arguments:
|
||||
/// - `name`: the domain name being challenged
|
||||
/// - `rtype`: the type of the record being removed
|
||||
pub fn remove_a_token(&self, name: Name, rtype: RecordType) {
|
||||
let mut token = self.tokens.lock().unwrap();
|
||||
let records = token.entry(name.to_lowercase()).or_default();
|
||||
for (index, (t, _)) in records.iter().enumerate() {
|
||||
if *t == rtype {
|
||||
tracing::debug!("Removing {rtype} token from name: `{name}`");
|
||||
records.remove(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a TXT token from the DNS worker
|
||||
///
|
||||
/// # Arguments:
|
||||
/// - `name`: the domain name being challenged
|
||||
/// - `rtype`: the type of the record being removed
|
||||
/// - `rdata`: the value of the TXT field for that challenge
|
||||
pub fn remove_txt_token(&self, name: Name, rdata: RData) {
|
||||
let mut token = self.tokens.lock().unwrap();
|
||||
let records = token.entry(name.to_lowercase()).or_default();
|
||||
for (index, (t, v)) in records.iter().enumerate() {
|
||||
if *t == RecordType::TXT && *v == rdata {
|
||||
tracing::debug!("Removing TXT token `{rdata}` from name: `{name}`");
|
||||
records.remove(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for User {}
|
||||
|
||||
impl PartialEq for User {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.login == other.login && self.key == other.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for User {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.login.hash(state);
|
||||
self.key.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_split() {
|
||||
let (subdomain, domain) = DnsRecord::split("example.com");
|
||||
assert_eq!(subdomain, "");
|
||||
assert_eq!(domain, "example.com");
|
||||
|
||||
let (subdomain, domain) = DnsRecord::split("sub.example.com");
|
||||
assert_eq!(subdomain, "sub");
|
||||
assert_eq!(domain, "example.com");
|
||||
|
||||
let (subdomain, domain) = DnsRecord::split("sub.sub.example.com");
|
||||
assert_eq!(subdomain, "sub.sub");
|
||||
assert_eq!(domain, "example.com");
|
||||
fn domain_normalization() {
|
||||
use std::str::FromStr;
|
||||
let domain = LowerName::from_str("example.com").unwrap();
|
||||
let subdomain = LowerName::from_str("www.EXAMPLE.com.").unwrap();
|
||||
println!("{domain} <=> {subdomain}");
|
||||
assert!(domain.zone_of(&subdomain))
|
||||
}
|
||||
|
||||
188
src/webapi.rs
188
src/webapi.rs
@@ -1,85 +1,147 @@
|
||||
use crate::AppState;
|
||||
use crate::config::Config;
|
||||
use crate::model::{DnsRecord, JsonDomain, User};
|
||||
use crate::model::{DnsRecords, JsonUpdate};
|
||||
use axum::{
|
||||
Router, extract,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
};
|
||||
use hickory_proto::rr::{
|
||||
LowerName, RData, RecordType,
|
||||
rdata::{a::A, a::Ipv4Addr, aaaa::AAAA, aaaa::Ipv6Addr, txt::TXT},
|
||||
};
|
||||
use http::{StatusCode, header::HeaderMap};
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateUserPayload {
|
||||
email: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn register(
|
||||
extract::Path(domain): extract::Path<String>,
|
||||
pub async fn update_a(
|
||||
extract::Path(domain): extract::Path<LowerName>,
|
||||
extract::State(state): extract::State<Arc<AppState>>,
|
||||
Json(body): Json<CreateUserPayload>,
|
||||
) -> Json<Value> {
|
||||
dbg!(&body);
|
||||
Json(json!({ "domain": domain,
|
||||
"email": body.email,
|
||||
"password": body.password.unwrap_or_default() }))
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let user = state.find_user(&headers)?;
|
||||
let name = qualify_name(
|
||||
&domain,
|
||||
&user.base_zone.as_ref().unwrap_or(&state.base_zone),
|
||||
);
|
||||
dbg!(&headers);
|
||||
let host = headers
|
||||
.get("x-real-ip")
|
||||
.or(headers.get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| IpAddr::from_str(v).ok())
|
||||
.unwrap_or(IpAddr::from_str("127.0.0.255").unwrap());
|
||||
if !state.is_allowed(&user, &name) {
|
||||
info!(
|
||||
"Unauthorized update attempt by user: {} @ {name} from {host}",
|
||||
user.login,
|
||||
);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
match host {
|
||||
IpAddr::V4(ip4) => {
|
||||
state
|
||||
.domains
|
||||
.add_token(name.to_lowercase(), RecordType::A, RData::A(A(ip4)));
|
||||
}
|
||||
IpAddr::V6(ip6) => {
|
||||
state.domains.add_token(
|
||||
name.to_lowercase(),
|
||||
RecordType::AAAA,
|
||||
RData::AAAA(AAAA(ip6)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "domain": name, "ip": host.to_string() })))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn update(
|
||||
extract::State(state): extract::State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
extract::Json(subdomain): extract::Json<JsonDomain>,
|
||||
extract::Json(JsonUpdate {
|
||||
rdata,
|
||||
rtype,
|
||||
domain,
|
||||
}): extract::Json<JsonUpdate>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let user = User::from(&headers);
|
||||
// let (_, domain) = DnsRecord::split(&subdomain.subdomain);
|
||||
if !state.is_allowed(&user, &subdomain.subdomain) {
|
||||
let user = state.find_user(&headers)?;
|
||||
let name = qualify_name(
|
||||
&domain,
|
||||
&user.base_zone.as_ref().unwrap_or(&state.base_zone),
|
||||
);
|
||||
if !state.is_allowed(&user, &name) {
|
||||
info!(
|
||||
"Unauthorized update attempt by user: {} @ {}",
|
||||
user.login, subdomain.subdomain
|
||||
"Unauthorized update attempt by user: {} @ {name}",
|
||||
user.login,
|
||||
);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
let sub = DnsRecord::from(subdomain);
|
||||
dbg!(&sub);
|
||||
if let Ok(mut domains) = state.domains.lock() {
|
||||
domains.replace(sub);
|
||||
Ok(Json(json!("OK")))
|
||||
} else {
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
let rdata = match rtype {
|
||||
RecordType::TXT => RData::TXT(TXT::from_bytes(vec![rdata.as_bytes()])),
|
||||
RecordType::A => RData::A(A(
|
||||
Ipv4Addr::from_str(&rdata).map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
)),
|
||||
RecordType::AAAA => RData::AAAA(AAAA(
|
||||
Ipv6Addr::from_str(&rdata).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
)),
|
||||
_ => return Err(StatusCode::NOT_ACCEPTABLE),
|
||||
};
|
||||
tracing::info!("Updating record for `{name}`");
|
||||
state.domains.add_token(name.to_lowercase(), rtype, rdata);
|
||||
|
||||
Ok(Json(json!("OK")))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn delete(
|
||||
extract::State(state): extract::State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
extract::Json(subdomain): extract::Json<JsonDomain>,
|
||||
extract::Json(JsonUpdate {
|
||||
rdata,
|
||||
rtype,
|
||||
domain,
|
||||
}): extract::Json<JsonUpdate>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let user = User::from(&headers);
|
||||
if !state.is_allowed(&user, &subdomain.subdomain) {
|
||||
let user = state.find_user(&headers)?;
|
||||
let name = qualify_name(
|
||||
&domain,
|
||||
&user.base_zone.as_ref().unwrap_or(&state.base_zone),
|
||||
);
|
||||
if !state.is_allowed(&user, &name) {
|
||||
info!(
|
||||
"Unauthorized update attempt by user: {} @ {}",
|
||||
user.login, subdomain.subdomain
|
||||
"Unauthorized update attempt by user: {} @ {name}",
|
||||
user.login,
|
||||
);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
let sub = DnsRecord::from(subdomain);
|
||||
if let Ok(mut domains) = state.domains.lock() {
|
||||
domains.remove(&sub);
|
||||
Ok(Json(json!("OK")))
|
||||
match rtype {
|
||||
RecordType::TXT => {
|
||||
let rdata = RData::TXT(TXT::from_bytes(vec![rdata.as_bytes()]));
|
||||
state.domains.remove_txt_token(name.to_lowercase(), rdata);
|
||||
Ok(Json(json!("OK")))
|
||||
}
|
||||
RecordType::AAAA | RecordType::A => {
|
||||
state.domains.remove_a_token(name.to_lowercase(), rtype);
|
||||
Ok(Json(json!("OK")))
|
||||
}
|
||||
_ => Err(StatusCode::NOT_ACCEPTABLE),
|
||||
}
|
||||
}
|
||||
|
||||
fn qualify_name(name: &LowerName, base_domain: &LowerName) -> LowerName {
|
||||
if !name.is_fqdn() {
|
||||
LowerName::new(&name.to_lowercase().append_domain(base_domain).unwrap())
|
||||
} else {
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +149,12 @@ pub async fn delete(
|
||||
pub async fn status(extract::State(state): extract::State<Arc<AppState>>) -> Markup {
|
||||
let domains: Vec<Markup> = state
|
||||
.domains
|
||||
.lock()
|
||||
.expect("Failed to lock AppState.domains")
|
||||
.get_all_tokens()
|
||||
.iter()
|
||||
.map(|d| {
|
||||
.map(|(name, (rt, token))| {
|
||||
html! {
|
||||
div .row {
|
||||
(d) div .cell { (d.age()) }
|
||||
(name) " [" (rt) "]:" (token)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -114,20 +175,31 @@ pub async fn status(extract::State(state): extract::State<Arc<AppState>>) -> Mar
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn health(headers: HeaderMap) -> Json<Value> {
|
||||
Json(json!({ "status": "OK" }))
|
||||
}
|
||||
|
||||
pub fn router(config: &Config) -> Router {
|
||||
pub fn router(config: &Config, records: DnsRecords) -> Router {
|
||||
let users = config
|
||||
.auths
|
||||
.iter()
|
||||
.inspect(|user| {
|
||||
if let Some(base_zone) = &user.base_zone
|
||||
&& !base_zone.is_fqdn()
|
||||
{
|
||||
panic!(
|
||||
"Field `{}.base_zone = {base_zone}` isn't a fully qualified domain name (ending with a dot)",
|
||||
user.login
|
||||
);
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let shared_state = Arc::new(AppState {
|
||||
auths: Arc::new(Mutex::new(config.auths.iter().cloned().collect())),
|
||||
domains: Arc::new(Mutex::new(HashSet::new())),
|
||||
auths: Arc::new(Mutex::new(users)),
|
||||
domains: records,
|
||||
base_zone: config.base_zone.clone(),
|
||||
});
|
||||
// build our application with a single route
|
||||
Router::new()
|
||||
.route("/", get(|| async { "Hello, World!" }))
|
||||
.route("/register/{domain}", post(register))
|
||||
.route("/", get(|| async { "TinyDNS running!" }))
|
||||
.route("/dyndns/{domain}", get(update_a))
|
||||
.route("/update", post(update))
|
||||
.route("/delete", post(delete))
|
||||
.route("/status", get(status))
|
||||
|
||||
Reference in New Issue
Block a user