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

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
config.toml

227
Cargo.lock generated
View File

@@ -2,6 +2,65 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "arraydeque" name = "arraydeque"
version = "0.5.1" version = "0.5.1"
@@ -135,6 +194,52 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "config" name = "config"
version = "0.15.19" version = "0.15.19"
@@ -670,6 +775,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -687,6 +798,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.179" version = "0.2.179"
@@ -705,6 +822,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -768,6 +894,15 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -793,6 +928,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.7.3" version = "0.7.3"
@@ -975,6 +1116,23 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1117,6 +1275,15 @@ dependencies = [
"digest", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1151,6 +1318,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.113" version = "2.0.113"
@@ -1199,6 +1372,15 @@ dependencies = [
"syn", "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]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.44"
@@ -1222,7 +1404,9 @@ checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
name = "tiny-dns" name = "tiny-dns"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"axum", "axum",
"clap",
"config", "config",
"hickory-proto", "hickory-proto",
"hickory-server", "hickory-server",
@@ -1233,6 +1417,7 @@ dependencies = [
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -1423,6 +1608,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "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]] [[package]]
@@ -1486,6 +1701,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View File

@@ -4,14 +4,17 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = "0.1.89"
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
hickory-server = "0.25" clap = { version = "4.4", features = ["derive"] }
config = "0.15"
hickory-proto = "0.25" hickory-proto = "0.25"
hickory-server = "0.25"
http = "1.4"
maud = { version = "0.27", features = ["axum"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 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"] } tower-http = { version = "0.6.8", features = ["fs", "tracing"] }
config = "0.15" tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -1,24 +1,41 @@
# tiny-dns # 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` : Creates a new or updates an existing DNS entry.
- `POST /update` : Aktualisiert einen vorhandenen DNS-Eintrag. - `POST /delete` : Deletes an DNS entry.
- `POST /delete` : Löscht einen vorhandenen DNS-Eintrag. - `GET /status` : Returns the status of the server.
- `GET /status` : Gibt den Status des Servers zurück. - `GET /dyndns/domain.example.com.` : Alternate way of updating DNS entries for DynDNS.
# Basiert auf DNS-01
Authorization via `x-api-user` & `x-api-key` header-fields.
### Update URL ### Update URL
```bash ```bash
curl http://\[::1\]:3000/update -H "X-Api-User: mig" -H "X-Api-Key: geheimnis" \ curl http://\[::1\]:3000/update -H "X-Api-User: <user>" -H "X-Api-Key: <super-secret>" \
--json '{"subdomain": "acme.norbb.de", "rdata": "___validation_token_received_from_the_ca___"}' --json '{"subdomain": "_acme-challenge.example.com", "rdata": "___validation_token_received_from_the_ca___"}'
``` ```
### Test URL ### Test URL
```bash ```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.

View File

@@ -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"]

20
config.toml.example Normal file
View File

@@ -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."

2
dist/styles.css vendored
View File

@@ -12,6 +12,4 @@
.cell { .cell {
display: table-cell; display: table-cell;
padding: 5px; padding: 5px;
border: 1px solid #ccc;
/* other styles */
} }

View File

@@ -1,27 +1,68 @@
use hickory_proto::rr::LowerName;
use serde::Deserialize; use serde::Deserialize;
use std::hash::Hash;
use crate::model::User;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(unused)] #[allow(unused)]
pub struct Config { pub struct Config {
pub dns_addr: Option<String>, pub dns_addr: Option<String>,
pub api_port: Option<u16>, pub api_port: Option<u16>,
pub zone_name: String, pub base_zone: LowerName,
#[serde(default)] #[serde(default)]
pub auths: Vec<User>, pub auths: Vec<User>,
} }
impl Config { impl Config {
pub fn new() -> Self { pub fn new(config_file: &Option<String>) -> Self {
use config::File; use config::File;
let config = config::Config::builder() let config = config::Config::builder()
.add_source(File::with_name("/etc/tiny-dns.toml").required(false)) .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() .build()
.expect("Failed to load config"); .expect("Failed to load config");
config let c: Self = config
.try_deserialize() .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 config;
mod dns;
mod model; mod model;
mod webapi; mod webapi;
use config::Config; use config::Config;
use model::AppState; 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] #[tokio::main]
async fn main() { async fn main() {
let config = Config::new(); let args = Args::parse();
let api = webapi::router(&config); tracing_subscriber::fmt()
let listen = config .with_env_filter(EnvFilter::from_default_env())
.api_port .with_max_level(args.loglevel())
.map(|port| format!("[::]:{port}")) .init();
.unwrap_or("[::]:3000".to_owned()); let config = Config::new(&args.config_file);
println!("Starting tiny-dns on: http://{listen}");
// run our app with hyper, listening globally on port 3000 let dns_listen = &config.dns_addr();
let api_listener = tokio::net::TcpListener::bind(listen).await.unwrap(); let web_api_listen = config.api_port();
// DNS server // 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(); // Web API server
catalog.upsert(LowerName::from_str("example.com").unwrap(), vec![]); let api = webapi::router(&config, dns_worker.dns_records().clone());
let mut server_future = ServerFuture::new(catalog);
let socket = UdpSocket::bind(&dns_addr) tracing::info!("Starting Web API on: http://{web_api_listen}");
.await let http_listener = tokio::net::TcpListener::bind(web_api_listen).await.unwrap();
.expect("Failed to bind DNS socket");
server_future.register_socket(socket); let api_handle = axum::serve(http_listener, api);
println!("DNS server running on {}", dns_addr); 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 _ = api_handle.await;
let _ = dns.await; let _ = dns_handle.await;
} }

View File

@@ -1,196 +1,30 @@
use hickory_proto::rr::RecordData; use crate::config::User;
use http::HeaderMap; use hickory_proto::rr::LowerName;
use maud::{Markup, Render, html}; use hickory_proto::rr::{Name, RData, RecordType};
use serde::{Deserialize, Serialize}; use http::{HeaderMap, StatusCode};
use std::{ use serde::Deserialize;
collections::HashSet, use std::collections::{HashMap, HashSet};
sync::{Arc, Mutex}, use std::sync::{Arc, Mutex};
};
use std::{
fmt::Display,
hash::Hash,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Default)] #[derive(Debug)]
pub struct AppState { pub struct AppState {
pub auths: Arc<Mutex<HashSet<User>>>, pub auths: Arc<Mutex<HashSet<User>>>,
pub domains: Arc<Mutex<HashSet<DnsRecord>>>, pub domains: DnsRecords,
pub base_zone: LowerName,
} }
impl AppState { impl AppState {
pub fn is_allowed(&self, user: &User, domain: impl Into<String>) -> bool { pub fn is_allowed(&self, user: &User, domain: &LowerName) -> bool {
let domain = domain.into(); self.auths.lock().is_ok_and(|auth| {
self.auths.lock().is_ok_and(|a| { auth.iter().any(|u| {
a.iter().any(|u| {
u.login == user.login u.login == user.login
&& u.key == user.key && 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)] pub fn find_user(&self, headers: &HeaderMap) -> Result<User, StatusCode> {
#[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 {
let login = headers let login = headers
.get("x-api-user") .get("x-api-user")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@@ -201,40 +35,148 @@ impl From<&HeaderMap> for User {
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
User { self.auths
login, .lock()
key, .unwrap()
scope: Vec::new(), .iter()
} .find(|u| u.login == login && u.key == key)
.cloned()
.ok_or(StatusCode::UNAUTHORIZED)
} }
} }
impl Eq for User {} #[derive(Debug, Deserialize)]
pub struct JsonUpdate {
impl PartialEq for User { pub domain: LowerName,
fn eq(&self, other: &Self) -> bool { #[serde(default = "txt_type")]
self.login == other.login && self.key == other.key pub rtype: hickory_proto::rr::RecordType,
} pub rdata: String,
} }
impl Hash for User { fn txt_type() -> hickory_proto::rr::RecordType {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { hickory_proto::rr::RecordType::TXT
self.login.hash(state); }
self.key.hash(state);
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;
}
}
} }
} }
#[test] #[test]
fn domain_split() { fn domain_normalization() {
let (subdomain, domain) = DnsRecord::split("example.com"); use std::str::FromStr;
assert_eq!(subdomain, ""); let domain = LowerName::from_str("example.com").unwrap();
assert_eq!(domain, "example.com"); let subdomain = LowerName::from_str("www.EXAMPLE.com.").unwrap();
println!("{domain} <=> {subdomain}");
let (subdomain, domain) = DnsRecord::split("sub.example.com"); assert!(domain.zone_of(&subdomain))
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");
} }

View File

@@ -1,85 +1,147 @@
use crate::AppState; use crate::AppState;
use crate::config::Config; use crate::config::Config;
use crate::model::{DnsRecord, JsonDomain, User}; use crate::model::{DnsRecords, JsonUpdate};
use axum::{ use axum::{
Router, extract, Router, extract,
response::Json, response::Json,
routing::{get, post}, 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 http::{StatusCode, header::HeaderMap};
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{ use std::net::IpAddr;
collections::HashSet, use std::str::FromStr;
sync::{Arc, Mutex}, use std::sync::{Arc, Mutex};
};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::info; use tracing::info;
#[derive(Deserialize, Debug)]
pub struct CreateUserPayload {
email: String,
password: Option<String>,
}
#[axum::debug_handler] #[axum::debug_handler]
pub async fn register( pub async fn update_a(
extract::Path(domain): extract::Path<String>, extract::Path(domain): extract::Path<LowerName>,
extract::State(state): extract::State<Arc<AppState>>, extract::State(state): extract::State<Arc<AppState>>,
Json(body): Json<CreateUserPayload>, headers: HeaderMap,
) -> Json<Value> { ) -> Result<Json<Value>, StatusCode> {
dbg!(&body); let user = state.find_user(&headers)?;
Json(json!({ "domain": domain, let name = qualify_name(
"email": body.email, &domain,
"password": body.password.unwrap_or_default() })) &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] #[axum::debug_handler]
pub async fn update( pub async fn update(
extract::State(state): extract::State<Arc<AppState>>, extract::State(state): extract::State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
extract::Json(subdomain): extract::Json<JsonDomain>, extract::Json(JsonUpdate {
rdata,
rtype,
domain,
}): extract::Json<JsonUpdate>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let user = User::from(&headers); let user = state.find_user(&headers)?;
// let (_, domain) = DnsRecord::split(&subdomain.subdomain); let name = qualify_name(
if !state.is_allowed(&user, &subdomain.subdomain) { &domain,
&user.base_zone.as_ref().unwrap_or(&state.base_zone),
);
if !state.is_allowed(&user, &name) {
info!( info!(
"Unauthorized update attempt by user: {} @ {}", "Unauthorized update attempt by user: {} @ {name}",
user.login, subdomain.subdomain user.login,
); );
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
} }
let sub = DnsRecord::from(subdomain);
dbg!(&sub); let rdata = match rtype {
if let Ok(mut domains) = state.domains.lock() { RecordType::TXT => RData::TXT(TXT::from_bytes(vec![rdata.as_bytes()])),
domains.replace(sub); 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"))) Ok(Json(json!("OK")))
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
} }
#[axum::debug_handler] #[axum::debug_handler]
pub async fn delete( pub async fn delete(
extract::State(state): extract::State<Arc<AppState>>, extract::State(state): extract::State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
extract::Json(subdomain): extract::Json<JsonDomain>, extract::Json(JsonUpdate {
rdata,
rtype,
domain,
}): extract::Json<JsonUpdate>,
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let user = User::from(&headers); let user = state.find_user(&headers)?;
if !state.is_allowed(&user, &subdomain.subdomain) { let name = qualify_name(
&domain,
&user.base_zone.as_ref().unwrap_or(&state.base_zone),
);
if !state.is_allowed(&user, &name) {
info!( info!(
"Unauthorized update attempt by user: {} @ {}", "Unauthorized update attempt by user: {} @ {name}",
user.login, subdomain.subdomain user.login,
); );
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
} }
let sub = DnsRecord::from(subdomain); match rtype {
if let Ok(mut domains) = state.domains.lock() { RecordType::TXT => {
domains.remove(&sub); let rdata = RData::TXT(TXT::from_bytes(vec![rdata.as_bytes()]));
state.domains.remove_txt_token(name.to_lowercase(), rdata);
Ok(Json(json!("OK"))) 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 { } 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 { pub async fn status(extract::State(state): extract::State<Arc<AppState>>) -> Markup {
let domains: Vec<Markup> = state let domains: Vec<Markup> = state
.domains .domains
.lock() .get_all_tokens()
.expect("Failed to lock AppState.domains")
.iter() .iter()
.map(|d| { .map(|(name, (rt, token))| {
html! { html! {
div .row { 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 fn router(config: &Config, records: DnsRecords) -> Router {
pub async fn health(headers: HeaderMap) -> Json<Value> { let users = config
Json(json!({ "status": "OK" })) .auths
} .iter()
.inspect(|user| {
pub fn router(config: &Config) -> Router { 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 { let shared_state = Arc::new(AppState {
auths: Arc::new(Mutex::new(config.auths.iter().cloned().collect())), auths: Arc::new(Mutex::new(users)),
domains: Arc::new(Mutex::new(HashSet::new())), domains: records,
base_zone: config.base_zone.clone(),
}); });
// build our application with a single route // build our application with a single route
Router::new() Router::new()
.route("/", get(|| async { "Hello, World!" })) .route("/", get(|| async { "TinyDNS running!" }))
.route("/register/{domain}", post(register)) .route("/dyndns/{domain}", get(update_a))
.route("/update", post(update)) .route("/update", post(update))
.route("/delete", post(delete)) .route("/delete", post(delete))
.route("/status", get(status)) .route("/status", get(status))