From 54567d3af4382bf0698c9ee644cd60e1d5e55917 Mon Sep 17 00:00:00 2001 From: Micha Glave Date: Mon, 12 Jan 2026 13:23:16 +0100 Subject: [PATCH] ready for beta-tests. --- .gitignore | 2 + Cargo.lock | 227 +++++++++++++++++++++++++++ Cargo.toml | 13 +- README.md | 37 +++-- config.toml | 14 -- config.toml.example | 20 +++ dist/styles.css | 2 - src/config.rs | 55 ++++++- src/dns.rs | 140 +++++++++++++++++ src/main.rs | 76 ++++++--- src/model.rs | 366 +++++++++++++++++++------------------------- src/webapi.rs | 188 ++++++++++++++++------- 12 files changed, 807 insertions(+), 333 deletions(-) delete mode 100644 config.toml create mode 100644 config.toml.example create mode 100644 src/dns.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..31d33e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +config.toml + diff --git a/Cargo.lock b/Cargo.lock index 38a0bd8..c7c4a8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,65 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -135,6 +194,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "config" version = "0.15.19" @@ -670,6 +775,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -687,6 +798,12 @@ dependencies = [ "serde", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.179" @@ -705,6 +822,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -768,6 +894,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -793,6 +928,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -975,6 +1116,23 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "ring" version = "0.17.14" @@ -1117,6 +1275,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1151,6 +1318,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.113" @@ -1199,6 +1372,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -1222,7 +1404,9 @@ checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" name = "tiny-dns" version = "0.1.0" dependencies = [ + "async-trait", "axum", + "clap", "config", "hickory-proto", "hickory-server", @@ -1233,6 +1417,7 @@ dependencies = [ "tokio", "tower-http", "tracing", + "tracing-subscriber", ] [[package]] @@ -1423,6 +1608,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1486,6 +1701,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 2c59ac9..7ccbc7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" axum = { version = "0.8", features = ["macros"] } -hickory-server = "0.25" +clap = { version = "4.4", features = ["derive"] } +config = "0.15" hickory-proto = "0.25" +hickory-server = "0.25" +http = "1.4" +maud = { version = "0.27", features = ["axum"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -maud = { version = "0.27", features = ["axum"] } -http = "1.4" -tracing = "0.1" tower-http = { version = "0.6.8", features = ["fs", "tracing"] } -config = "0.15" +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index fd8eb79..f7d0de3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,41 @@ # tiny-dns -Ein einfacher DNS-Server, der für ACME DNS Abfragen genutzt werden kann. +A simple DNS server that can be used to answer ACME DNS-01 or DynDNS queries. -- `POST /register` : Registriert einen neuen DNS-Eintrag. -- `POST /update` : Aktualisiert einen vorhandenen DNS-Eintrag. -- `POST /delete` : Löscht einen vorhandenen DNS-Eintrag. -- `GET /status` : Gibt den Status des Servers zurück. - -# Basiert auf DNS-01 +- `POST /update` : Creates a new or updates an existing DNS entry. +- `POST /delete` : Deletes an DNS entry. +- `GET /status` : Returns the status of the server. +- `GET /dyndns/domain.example.com.` : Alternate way of updating DNS entries for DynDNS. +Authorization via `x-api-user` & `x-api-key` header-fields. ### Update URL ```bash -curl http://\[::1\]:3000/update -H "X-Api-User: mig" -H "X-Api-Key: geheimnis" \ ---json '{"subdomain": "acme.norbb.de", "rdata": "___validation_token_received_from_the_ca___"}' +curl http://\[::1\]:3000/update -H "X-Api-User: " -H "X-Api-Key: " \ +--json '{"subdomain": "_acme-challenge.example.com", "rdata": "___validation_token_received_from_the_ca___"}' ``` ### Test URL ```bash -dig @localhost -p 8053 -t TXT acme.norbb.de +dig @localhost -p 3053 -t TXT _acme-challenge.example.com ``` + +### DNS Config + +```bind + +example.com. IN A 192.0.2.1 +www.example.com. IN CNAME example.com. +acme-dns.example.com. IN A 192.0.2.2 + +_acme-challenge.example.com. IN NS acme-dns.example.com. +_acme-challenge.www.example.com. IN NS acme-dns.example.com. +``` + + +### Prior art + +- [agnos](https://github.com/krtab/agnos) - Uses the DNS-NS entries, but no webapi +- [acme-dns](https://github.com/joohoi/acme-dns) - Provides a webapi for managing DNS entries. diff --git a/config.toml b/config.toml deleted file mode 100644 index cc9481a..0000000 --- a/config.toml +++ /dev/null @@ -1,14 +0,0 @@ -#api_port = 3002 -dns_addr = "localhost:8053" - -zone_name = "acme.example.com" - -[[auths]] -login = "mig" -key = "geheimnis" -scope = ["norbb.de", "domain2"] - -[[auths]] -login = "user" -key = "secret" -scope = ["example.com"] diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..ab6b156 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,20 @@ +# Port of the API server +#api_port = 3000 +# Address of the DNS server +#dns_addr = "localhost:3053" +# Zone name for DNS updates without base domain. (mandatory) +base_zone = "acme.example.com." + +[[auths]] +login = "me" +key = "secret" +scope = ["example.net.", "sub.example.com."] +# Zone name for DNS updates without base domain. (optional) +#base_zone = "example.net." + +[[auths]] +login = "user" +key = "secret" +scope = ["example.com"] +# Zone name for DNS updates without base domain. (optional) +#base_zone = "acme.example.com." diff --git a/dist/styles.css b/dist/styles.css index 6d5c278..3505eca 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -12,6 +12,4 @@ .cell { display: table-cell; padding: 5px; - border: 1px solid #ccc; - /* other styles */ } diff --git a/src/config.rs b/src/config.rs index 8253be3..3473898 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, pub api_port: Option, - pub zone_name: String, + pub base_zone: LowerName, #[serde(default)] pub auths: Vec, } impl Config { - pub fn new() -> Self { + pub fn new(config_file: &Option) -> 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, + pub base_zone: Option, +} + +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(&self, state: &mut H) { + self.login.hash(state); + self.key.hash(state); } } diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 0000000..00d9546 --- /dev/null +++ b/src/dns.rs @@ -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( + &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 + Send>, + Box::new(None.iter()) as Box + Send>, + Box::new(None.iter()) as Box + Send>, + Box::new(None.iter()) as Box + 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, + dns_records: DnsRecords, +} + +impl DnsWorker { + /// Create a new DnsWorker + pub async fn new(listening_addr: &str) -> std::io::Result { + 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 + } +} diff --git a/src/main.rs b/src/main.rs index be009ef..b4c8502 100644 --- a/src/main.rs +++ b/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, + + #[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; } diff --git a/src/model.rs b/src/model.rs index 767c66c..5835096 100644 --- a/src/model.rs +++ b/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>>, - pub domains: Arc>>, + pub domains: DnsRecords, + pub base_zone: LowerName, } impl AppState { - pub fn is_allowed(&self, user: &User, domain: impl Into) -> 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, - 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 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(&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 { - todo!() - } -} - -#[derive(Debug, Deserialize, Clone)] -pub struct User { - pub login: String, - pub key: String, - pub scope: Vec, -} - -impl From<&HeaderMap> for User { - fn from(headers: &HeaderMap) -> Self { + pub fn find_user(&self, headers: &HeaderMap) -> Result { 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>>>, +} + +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 { + 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::>() + }) + .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(&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)) } diff --git a/src/webapi.rs b/src/webapi.rs index dec5fad..1a512b7 100644 --- a/src/webapi.rs +++ b/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, -} - #[axum::debug_handler] -pub async fn register( - extract::Path(domain): extract::Path, +pub async fn update_a( + extract::Path(domain): extract::Path, extract::State(state): extract::State>, - Json(body): Json, -) -> Json { - dbg!(&body); - Json(json!({ "domain": domain, - "email": body.email, - "password": body.password.unwrap_or_default() })) + headers: HeaderMap, +) -> Result, 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>, headers: HeaderMap, - extract::Json(subdomain): extract::Json, + extract::Json(JsonUpdate { + rdata, + rtype, + domain, + }): extract::Json, ) -> Result, 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>, headers: HeaderMap, - extract::Json(subdomain): extract::Json, + extract::Json(JsonUpdate { + rdata, + rtype, + domain, + }): extract::Json, ) -> Result, 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>) -> Markup { let domains: Vec = 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>) -> Mar } } -#[axum::debug_handler] -pub async fn health(headers: HeaderMap) -> Json { - 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))