axum based webservice works.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1422
Cargo.lock
generated
Normal file
1422
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "tiny-dns"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
hickory-server = "0.25"
|
||||||
|
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"] }
|
||||||
10
README.md
Normal file
10
README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# tiny-dns
|
||||||
|
|
||||||
|
Ein einfacher DNS-Server, der für ACME DNS Abfragen genutzt werden kann.
|
||||||
|
|
||||||
|
- `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
|
||||||
17
dist/styles.css
vendored
Normal file
17
dist/styles.css
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.table {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: table-row;
|
||||||
|
width: 30%;
|
||||||
|
border: 1px #888 solid;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
display: table-cell;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
/* other styles */
|
||||||
|
}
|
||||||
171
src/main.rs
Normal file
171
src/main.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use axum::{
|
||||||
|
Router, extract,
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
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 tower_http::services::ServeDir;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
mod model;
|
||||||
|
use model::{DnsRecord, User};
|
||||||
|
|
||||||
|
use crate::model::JsonDomain;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct AppState {
|
||||||
|
auths: Arc<Mutex<HashSet<User>>>,
|
||||||
|
domains: Arc<Mutex<HashSet<DnsRecord>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn is_allowed(&self, user: &User, domain: impl Into<String>) -> bool {
|
||||||
|
let domain = domain.into();
|
||||||
|
self.auths.lock().map_or(false, |a| {
|
||||||
|
a.iter().any(|u| {
|
||||||
|
u.login == user.login
|
||||||
|
&& u.key == user.key
|
||||||
|
&& u.scope.iter().any(|s| domain.contains(s))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let shared_state = Arc::new(AppState {
|
||||||
|
auths: Arc::new(Mutex::new(
|
||||||
|
[User {
|
||||||
|
key: "geheimnis".to_string(),
|
||||||
|
login: "mig".to_string(),
|
||||||
|
scope: vec!["norbb.de".to_string(), "domain2".to_string()],
|
||||||
|
}]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
)),
|
||||||
|
domains: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
});
|
||||||
|
// build our application with a single route
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(|| async { "Hello, World!" }))
|
||||||
|
.route("/register/{domain}", post(register))
|
||||||
|
.route("/update", post(update))
|
||||||
|
.route("/delete", post(delete))
|
||||||
|
.route("/status", get(status))
|
||||||
|
.nest_service("/dist", ServeDir::new("dist"))
|
||||||
|
.with_state(shared_state);
|
||||||
|
let listen = "[::]:3000";
|
||||||
|
println!("Starting tiny-dns on: http://{listen}");
|
||||||
|
// run our app with hyper, listening globally on port 3000
|
||||||
|
let listener = tokio::net::TcpListener::bind(listen).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct CreateUserPayload {
|
||||||
|
email: String,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn register(
|
||||||
|
extract::Path(domain): extract::Path<String>,
|
||||||
|
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() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn update(
|
||||||
|
extract::State(state): extract::State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
extract::Json(subdomain): extract::Json<JsonDomain>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let user = User::from(&headers);
|
||||||
|
// let (_, domain) = DnsRecord::split(&subdomain.subdomain);
|
||||||
|
if !state.is_allowed(&user, &subdomain.subdomain) {
|
||||||
|
info!(
|
||||||
|
"Unauthorized update attempt by user: {} @ {}",
|
||||||
|
user.login, subdomain.subdomain
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn delete(
|
||||||
|
extract::State(state): extract::State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
extract::Json(subdomain): extract::Json<JsonDomain>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let user = User::from(&headers);
|
||||||
|
if !state.is_allowed(&user, &subdomain.subdomain) {
|
||||||
|
info!(
|
||||||
|
"Unauthorized update attempt by user: {} @ {}",
|
||||||
|
user.login, subdomain.subdomain
|
||||||
|
);
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
let sub = DnsRecord::from(subdomain);
|
||||||
|
if let Ok(mut domains) = state.domains.lock() {
|
||||||
|
domains.remove(&sub);
|
||||||
|
Ok(Json(json!("OK")))
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn status(extract::State(state): extract::State<Arc<AppState>>) -> Markup {
|
||||||
|
let domains: Vec<Markup> = state
|
||||||
|
.domains
|
||||||
|
.lock()
|
||||||
|
.expect("Failed to lock AppState.domains")
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
html! {
|
||||||
|
div .row {
|
||||||
|
(d) div .cell { (d.age()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
meta charset="utf-8";
|
||||||
|
title { "Status" }
|
||||||
|
link rel="stylesheet" href="dist/styles.css";
|
||||||
|
h1 { "Status" }
|
||||||
|
h4 { "Domains"}
|
||||||
|
div .table {
|
||||||
|
@for le in &domains {
|
||||||
|
{ (le) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn health(headers: HeaderMap) -> Json<Value> {
|
||||||
|
Json(json!({ "status": "OK" }))
|
||||||
|
}
|
||||||
194
src/model.rs
Normal file
194
src/model.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use http::HeaderMap;
|
||||||
|
use maud::{Markup, Render, html};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
hash::Hash,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
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
|
||||||
|
.get("x-api-user")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let key = headers
|
||||||
|
.get("x-api-key")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
User {
|
||||||
|
login,
|
||||||
|
key,
|
||||||
|
scope: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user