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