ready for beta-tests.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
|
config.toml
|
||||||
|
|
||||||
|
|||||||
227
Cargo.lock
generated
227
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -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"] }
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -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.
|
||||||
|
|||||||
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 {
|
.cell {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border: 1px solid #ccc;
|
|
||||||
/* other styles */
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
358
src/model.rs
358
src/model.rs
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/webapi.rs
184
src/webapi.rs
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user