axum based webservice works.

This commit is contained in:
Micha Glave
2026-01-06 14:25:54 +01:00
commit 4c86b21ca7
7 changed files with 1830 additions and 0 deletions

171
src/main.rs Normal file
View 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
View 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");
}