ready for beta-tests.

This commit is contained in:
2026-01-12 13:23:16 +01:00
parent 7f15efc0cd
commit 54567d3af4
12 changed files with 807 additions and 333 deletions

View File

@@ -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
View 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
}
}

View File

@@ -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;
}

View File

@@ -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))
}

View File

@@ -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))