show(), hide(), visible. font-light, normal, medium, semibold, bold, extrabold, black. protocol connection pooling. fetch() with GURT. DNS from HTTP to GURT.

This commit is contained in:
Face
2025-08-18 17:45:46 +03:00
parent 3ed49fae0d
commit a8313ec3d8
38 changed files with 2123 additions and 2059 deletions

View File

@@ -1,7 +1,4 @@
use actix_web::{dev::ServiceRequest, web, Error, HttpMessage};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use actix_web_httpauth::extractors::AuthenticationError;
use actix_web_httpauth::headers::www_authenticate::bearer::Bearer;
use gurt::prelude::*;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use bcrypt::{hash, verify, DEFAULT_COST};
@@ -42,7 +39,7 @@ pub struct UserInfo {
pub created_at: DateTime<Utc>,
}
pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> std::result::Result<String, jsonwebtoken::errors::Error> {
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -57,7 +54,7 @@ pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> Result<String
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
}
pub fn validate_jwt(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
pub fn validate_jwt(token: &str, secret: &str) -> std::result::Result<Claims, jsonwebtoken::errors::Error> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
@@ -65,31 +62,39 @@ pub fn validate_jwt(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::e
.map(|token_data| token_data.claims)
}
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
pub fn hash_password(password: &str) -> std::result::Result<String, bcrypt::BcryptError> {
hash(password, DEFAULT_COST)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> {
pub fn verify_password(password: &str, hash: &str) -> std::result::Result<bool, bcrypt::BcryptError> {
verify(password, hash)
}
pub async fn jwt_middleware(
req: ServiceRequest,
credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
let jwt_secret = req
.app_data::<web::Data<String>>()
.unwrap()
.as_ref();
pub async fn jwt_middleware_gurt(ctx: &ServerContext, jwt_secret: &str) -> Result<Claims> {
let start_time = std::time::Instant::now();
log::info!("JWT middleware started for {} {}", ctx.method(), ctx.path());
let auth_header = ctx.header("authorization")
.or_else(|| ctx.header("Authorization"))
.ok_or_else(|| {
log::warn!("JWT middleware failed: Missing Authorization header in {:?}", start_time.elapsed());
GurtError::invalid_message("Missing Authorization header")
})?;
match validate_jwt(credentials.token(), jwt_secret) {
Ok(claims) => {
req.extensions_mut().insert(claims);
Ok(req)
}
Err(_) => {
let config = AuthenticationError::new(Bearer::default());
Err((Error::from(config), req))
}
if !auth_header.starts_with("Bearer ") {
log::warn!("JWT middleware failed: Invalid header format in {:?}", start_time.elapsed());
return Err(GurtError::invalid_message("Invalid Authorization header format"));
}
let token = &auth_header[7..]; // Remove "Bearer " prefix
let result = validate_jwt(token, jwt_secret)
.map_err(|e| GurtError::invalid_message(format!("Invalid JWT token: {}", e)));
match &result {
Ok(_) => log::info!("JWT middleware completed successfully in {:?}", start_time.elapsed()),
Err(e) => log::warn!("JWT middleware failed: {} in {:?}", e, start_time.elapsed()),
}
result
}

View File

@@ -25,6 +25,8 @@ impl Config {
url: "postgresql://username:password@localhost/domains".into(),
max_connections: 10,
},
cert_path: "localhost+2.pem".into(),
key_path: "localhost+2-key.pem".into(),
},
discord: Discord {
bot_token: "".into(),

View File

@@ -15,6 +15,8 @@ pub struct Server {
pub(crate) address: String,
pub(crate) port: u64,
pub(crate) database: Database,
pub(crate) cert_path: String,
pub(crate) key_path: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]

216
dns/src/gurt_server.rs Normal file
View File

@@ -0,0 +1,216 @@
mod auth_routes;
mod helpers;
mod models;
mod routes;
use crate::{auth::jwt_middleware_gurt, config::Config, discord_bot};
use colored::Colorize;
use macros_rs::fmt::{crashln, string};
use std::{net::IpAddr, str::FromStr, sync::Arc, collections::HashMap};
use gurt::prelude::*;
use gurt::{GurtStatusCode, Route};
#[derive(Clone)]
pub(crate) struct AppState {
trusted: IpAddr,
config: Config,
db: sqlx::PgPool,
jwt_secret: String,
}
impl AppState {
pub fn new(trusted: IpAddr, config: Config, db: sqlx::PgPool, jwt_secret: String) -> Self {
Self {
trusted,
config,
db,
jwt_secret,
}
}
}
#[derive(Clone)]
pub(crate) struct RateLimitState {
limits: Arc<tokio::sync::RwLock<HashMap<String, Vec<chrono::DateTime<chrono::Utc>>>>>,
}
impl RateLimitState {
pub fn new() -> Self {
Self {
limits: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
}
}
pub async fn check_rate_limit(&self, key: &str, window_secs: i64, max_requests: usize) -> bool {
let mut limits = self.limits.write().await;
let now = chrono::Utc::now();
let window_start = now - chrono::Duration::seconds(window_secs);
let entry = limits.entry(key.to_string()).or_insert_with(Vec::new);
entry.retain(|&timestamp| timestamp > window_start);
if entry.len() >= max_requests {
false
} else {
entry.push(now);
true
}
}
}
struct AppHandler {
app_state: AppState,
rate_limit_state: Option<RateLimitState>,
handler_type: HandlerType,
}
// Macro to reduce JWT middleware duplication
macro_rules! handle_authenticated {
($ctx:expr, $app_state:expr, $handler:expr) => {
match jwt_middleware_gurt(&$ctx, &$app_state.jwt_secret).await {
Ok(claims) => $handler(&$ctx, $app_state, claims).await,
Err(e) => Ok(GurtResponse::new(GurtStatusCode::Unauthorized)
.with_string_body(&format!("Authentication failed: {}", e))),
}
};
}
#[derive(Clone)]
enum HandlerType {
Index,
GetDomain,
GetDomains,
GetTlds,
CheckDomain,
Register,
Login,
GetUserInfo,
CreateInvite,
RedeemInvite,
CreateDomainInvite,
RedeemDomainInvite,
CreateDomain,
UpdateDomain,
DeleteDomain,
}
impl GurtHandler for AppHandler {
fn handle(&self, ctx: &ServerContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GurtResponse>> + Send + '_>> {
let app_state = self.app_state.clone();
let rate_limit_state = self.rate_limit_state.clone();
let handler_type = self.handler_type.clone();
let ctx_data = (
ctx.remote_addr,
ctx.request.clone(),
);
Box::pin(async move {
let start_time = std::time::Instant::now();
let ctx = ServerContext {
remote_addr: ctx_data.0,
request: ctx_data.1,
};
log::info!("Handler started for {} {} from {}", ctx.method(), ctx.path(), ctx.remote_addr);
let result = match handler_type {
HandlerType::Index => routes::index(app_state).await,
HandlerType::GetDomain => routes::get_domain(&ctx, app_state).await,
HandlerType::GetDomains => routes::get_domains(&ctx, app_state).await,
HandlerType::GetTlds => routes::get_tlds(app_state).await,
HandlerType::CheckDomain => routes::check_domain(&ctx, app_state).await,
HandlerType::Register => auth_routes::register(&ctx, app_state).await,
HandlerType::Login => auth_routes::login(&ctx, app_state).await,
HandlerType::GetUserInfo => handle_authenticated!(ctx, app_state, auth_routes::get_user_info),
HandlerType::CreateInvite => handle_authenticated!(ctx, app_state, auth_routes::create_invite),
HandlerType::RedeemInvite => handle_authenticated!(ctx, app_state, auth_routes::redeem_invite),
HandlerType::CreateDomainInvite => handle_authenticated!(ctx, app_state, auth_routes::create_domain_invite),
HandlerType::RedeemDomainInvite => handle_authenticated!(ctx, app_state, auth_routes::redeem_domain_invite),
HandlerType::CreateDomain => {
// Check rate limit first
if let Some(ref rate_limit_state) = rate_limit_state {
let client_ip = ctx.client_ip().to_string();
if !rate_limit_state.check_rate_limit(&client_ip, 600, 5).await {
return Ok(GurtResponse::new(GurtStatusCode::TooLarge).with_string_body("Rate limit exceeded: 5 requests per 10 minutes"));
}
}
handle_authenticated!(ctx, app_state, routes::create_domain)
},
HandlerType::UpdateDomain => handle_authenticated!(ctx, app_state, routes::update_domain),
HandlerType::DeleteDomain => handle_authenticated!(ctx, app_state, routes::delete_domain),
};
let duration = start_time.elapsed();
match &result {
Ok(response) => {
log::info!("Handler completed for {} {} in {:?} - Status: {}",
ctx.method(), ctx.path(), duration, response.status_code);
},
Err(e) => {
log::error!("Handler failed for {} {} in {:?} - Error: {}",
ctx.method(), ctx.path(), duration, e);
}
}
result
})
}
}
pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
let config = Config::new().set_path(&cli.config).read();
let trusted_ip = match IpAddr::from_str(&config.server.address) {
Ok(addr) => addr,
Err(err) => crashln!("Cannot parse address.\n{}", string!(err).white()),
};
let db = match config.connect_to_db().await {
Ok(pool) => pool,
Err(err) => crashln!("Failed to connect to PostgreSQL database.\n{}", string!(err).white()),
};
// Start Discord bot
if !config.discord.bot_token.is_empty() {
if let Err(e) = discord_bot::start_discord_bot(config.discord.bot_token.clone(), db.clone()).await {
log::error!("Failed to start Discord bot: {}", e);
}
}
let jwt_secret = config.auth.jwt_secret.clone();
let app_state = AppState::new(trusted_ip, config.clone(), db, jwt_secret);
let rate_limit_state = RateLimitState::new();
// Create GURT server
let mut server = GurtServer::new();
// Load TLS certificates
if let Err(e) = server.load_tls_certificates(&config.server.cert_path, &config.server.key_path) {
crashln!("Failed to load TLS certificates: {}", e);
}
server = server
.route(Route::get("/"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Index })
.route(Route::get("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetDomain })
.route(Route::get("/domains"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetDomains })
.route(Route::get("/tlds"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetTlds })
.route(Route::get("/check"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CheckDomain })
.route(Route::post("/auth/register"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Register })
.route(Route::post("/auth/login"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::Login })
.route(Route::get("/auth/me"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetUserInfo })
.route(Route::post("/auth/invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CreateInvite })
.route(Route::post("/auth/redeem-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RedeemInvite })
.route(Route::post("/auth/create-domain-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CreateDomainInvite })
.route(Route::post("/auth/redeem-domain-invite"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RedeemDomainInvite })
.route(Route::post("/domain"), AppHandler { app_state: app_state.clone(), rate_limit_state: Some(rate_limit_state), handler_type: HandlerType::CreateDomain })
.route(Route::put("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::UpdateDomain })
.route(Route::delete("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::DeleteDomain });
log::info!("GURT server listening on {}", config.get_address());
server.listen(&config.get_address()).await.map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("GURT server error: {}", e))
})
}

View File

@@ -0,0 +1,402 @@
use super::{models::*, AppState};
use crate::auth::*;
use gurt::prelude::*;
use gurt::GurtStatusCode;
use sqlx::Row;
use chrono::Utc;
pub(crate) async fn register(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let user: RegisterRequest = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let registrations = 3; // New users get 3 registrations by default
// Hash password
let password_hash = match hash_password(&user.password) {
Ok(hash) => hash,
Err(_) => {
return Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Failed to hash password",
error: "HASH_ERROR".into(),
})?);
}
};
// Create user
let user_result = sqlx::query(
"INSERT INTO users (username, password_hash, registrations_remaining, domain_invite_codes) VALUES ($1, $2, $3, $4) RETURNING id"
)
.bind(&user.username)
.bind(&password_hash)
.bind(registrations)
.bind(3) // Default 3 domain invite codes
.fetch_one(&app_state.db)
.await;
match user_result {
Ok(row) => {
let user_id: i32 = row.get("id");
// Generate JWT
match generate_jwt(user_id, &user.username, &app_state.jwt_secret) {
Ok(token) => {
let response = LoginResponse {
token,
user: UserInfo {
id: user_id,
username: user.username.clone(),
registrations_remaining: registrations,
domain_invite_codes: 3,
created_at: Utc::now(),
},
};
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Failed to generate token",
error: "JWT_ERROR".into(),
})?)
}
}
}
Err(e) => {
if e.to_string().contains("duplicate key") {
Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "Username already exists",
error: "DUPLICATE_USERNAME".into(),
})?)
} else {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Failed to create user",
error: e.to_string(),
})?)
}
}
}
}
pub(crate) async fn login(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let body_bytes = ctx.body();
let login_req: LoginRequest = serde_json::from_slice(body_bytes)
.map_err(|e| {
log::error!("JSON parse error: {}", e);
GurtError::invalid_message("Invalid JSON")
})?;
// Find user
let user_result = sqlx::query_as::<_, User>(
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE username = $1"
)
.bind(&login_req.username)
.fetch_optional(&app_state.db)
.await;
match user_result {
Ok(Some(user)) => {
// Verify password
match verify_password(&login_req.password, &user.password_hash) {
Ok(true) => {
// Generate JWT
match generate_jwt(user.id, &user.username, &app_state.jwt_secret) {
Ok(token) => {
let response = LoginResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
registrations_remaining: user.registrations_remaining,
domain_invite_codes: user.domain_invite_codes,
created_at: user.created_at,
},
};
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Failed to generate token",
error: "JWT_ERROR".into(),
})?)
}
}
}
Ok(false) => {
Ok(GurtResponse::new(GurtStatusCode::Unauthorized).with_json_body(&Error {
msg: "Invalid credentials",
error: "INVALID_CREDENTIALS".into(),
})?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Password verification failed",
error: "PASSWORD_ERROR".into(),
})?)
}
}
}
Ok(None) => {
Ok(GurtResponse::new(GurtStatusCode::Unauthorized).with_json_body(&Error {
msg: "Invalid credentials",
error: "INVALID_CREDENTIALS".into(),
})?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Database error",
error: "DATABASE_ERROR".into(),
})?)
}
}
}
pub(crate) async fn get_user_info(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let user_result = sqlx::query_as::<_, User>(
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE id = $1"
)
.bind(claims.user_id)
.fetch_optional(&app_state.db)
.await;
match user_result {
Ok(Some(user)) => {
let user_info = UserInfo {
id: user.id,
username: user.username,
registrations_remaining: user.registrations_remaining,
domain_invite_codes: user.domain_invite_codes,
created_at: user.created_at,
};
Ok(GurtResponse::ok().with_json_body(&user_info)?)
}
Ok(None) => {
Ok(GurtResponse::not_found().with_json_body(&Error {
msg: "User not found",
error: "USER_NOT_FOUND".into(),
})?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Database error",
error: "DATABASE_ERROR".into(),
})?)
}
}
}
pub(crate) async fn create_invite(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
// Generate random invite code
let invite_code: String = {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..12)
.map(|_| {
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
chars[rng.gen_range(0..chars.len())] as char
})
.collect()
};
// Insert invite code into database
let insert_result = sqlx::query(
"INSERT INTO invite_codes (code, created_by, created_at) VALUES ($1, $2, $3)"
)
.bind(&invite_code)
.bind(claims.user_id)
.bind(Utc::now())
.execute(&app_state.db)
.await;
match insert_result {
Ok(_) => {
let response = serde_json::json!({
"invite_code": invite_code
});
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Failed to create invite",
error: "DATABASE_ERROR".into(),
})?)
}
}
}
pub(crate) async fn redeem_invite(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let request: serde_json::Value = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let invite_code = request["invite_code"].as_str()
.ok_or(GurtError::invalid_message("Missing invite_code"))?;
// Check if invite code exists and is not used
let invite_result = sqlx::query_as::<_, InviteCode>(
"SELECT id, code, created_by, used_by, created_at, used_at FROM invite_codes WHERE code = $1 AND used_by IS NULL"
)
.bind(invite_code)
.fetch_optional(&app_state.db)
.await;
match invite_result {
Ok(Some(invite)) => {
// Mark invite as used and give user 3 additional registrations
let mut tx = app_state.db.begin().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query("UPDATE invite_codes SET used_by = $1, used_at = $2 WHERE id = $3")
.bind(claims.user_id)
.bind(Utc::now())
.bind(invite.id)
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query("UPDATE users SET registrations_remaining = registrations_remaining + 3 WHERE id = $1")
.bind(claims.user_id)
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
tx.commit().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let response = serde_json::json!({
"registrations_added": 3
});
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Ok(None) => {
Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "Invalid or already used invite code",
error: "INVALID_INVITE".into(),
})?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Database error",
error: "DATABASE_ERROR".into(),
})?)
}
}
}
pub(crate) async fn create_domain_invite(_ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
// Check if user has domain invite codes remaining
let user: (i32,) = sqlx::query_as("SELECT domain_invite_codes FROM users WHERE id = $1")
.bind(claims.user_id)
.fetch_one(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("User not found"))?;
if user.0 <= 0 {
return Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "No domain invite codes remaining",
error: "NO_INVITES_REMAINING".into(),
})?);
}
// Generate random domain invite code
let invite_code: String = {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..12)
.map(|_| {
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
chars[rng.gen_range(0..chars.len())] as char
})
.collect()
};
// Insert domain invite code and decrease user's count
let mut tx = app_state.db.begin().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query(
"INSERT INTO domain_invite_codes (code, created_by, created_at) VALUES ($1, $2, $3)"
)
.bind(&invite_code)
.bind(claims.user_id)
.bind(Utc::now())
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query("UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1")
.bind(claims.user_id)
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
tx.commit().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let response = serde_json::json!({
"domain_invite_code": invite_code
});
Ok(GurtResponse::ok().with_json_body(&response)?)
}
pub(crate) async fn redeem_domain_invite(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let request: serde_json::Value = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let invite_code = request["domain_invite_code"].as_str()
.ok_or(GurtError::invalid_message("Missing domain_invite_code"))?;
// Check if domain invite code exists and is not used
let invite_result = sqlx::query_as::<_, DomainInviteCode>(
"SELECT id, code, created_by, used_by, created_at, used_at FROM domain_invite_codes WHERE code = $1 AND used_by IS NULL"
)
.bind(invite_code)
.fetch_optional(&app_state.db)
.await;
match invite_result {
Ok(Some(invite)) => {
// Mark invite as used and give user 1 additional domain invite code
let mut tx = app_state.db.begin().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query("UPDATE domain_invite_codes SET used_by = $1, used_at = $2 WHERE id = $3")
.bind(claims.user_id)
.bind(Utc::now())
.bind(invite.id)
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
sqlx::query("UPDATE users SET domain_invite_codes = domain_invite_codes + 1 WHERE id = $1")
.bind(claims.user_id)
.execute(&mut *tx)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
tx.commit().await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let response = serde_json::json!({
"domain_invite_codes_added": 1
});
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Ok(None) => {
Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "Invalid or already used domain invite code",
error: "INVALID_DOMAIN_INVITE".into(),
})?)
}
Err(_) => {
Ok(GurtResponse::internal_server_error().with_json_body(&Error {
msg: "Database error",
error: "DATABASE_ERROR".into(),
})?)
}
}
}
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,
error: String,
}

View File

@@ -0,0 +1,20 @@
use gurt::prelude::*;
use std::net::IpAddr;
pub fn validate_ip(domain: &super::models::Domain) -> Result<()> {
if domain.ip.parse::<IpAddr>().is_err() {
return Err(GurtError::invalid_message("Invalid IP address"));
}
Ok(())
}
pub fn deserialize_lowercase<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let s = String::deserialize(deserializer)?;
Ok(s.to_lowercase())
}

View File

@@ -80,7 +80,7 @@ pub(crate) struct ResponseDnsRecord {
pub(crate) record_type: String,
pub(crate) name: String,
pub(crate) value: String,
pub(crate) ttl: i32,
pub(crate) ttl: Option<i32>,
pub(crate) priority: Option<i32>,
}
@@ -89,27 +89,6 @@ pub(crate) struct UpdateDomain {
pub(crate) ip: String,
}
#[derive(Serialize)]
pub(crate) struct Error {
pub(crate) msg: &'static str,
pub(crate) error: String,
}
#[derive(Serialize)]
pub(crate) struct Ratelimit {
pub(crate) msg: String,
pub(crate) error: &'static str,
pub(crate) after: u64,
}
#[derive(Deserialize)]
pub(crate) struct PaginationParams {
#[serde(alias = "p", alias = "doc")]
pub(crate) page: Option<u32>,
#[serde(alias = "s", alias = "size", alias = "l", alias = "limit")]
pub(crate) page_size: Option<u32>,
}
#[derive(Serialize)]
pub(crate) struct PaginationResponse {
pub(crate) domains: Vec<ResponseDomain>,
@@ -117,12 +96,6 @@ pub(crate) struct PaginationResponse {
pub(crate) limit: u32,
}
#[derive(Deserialize)]
pub(crate) struct DomainQuery {
pub(crate) name: String,
pub(crate) tld: Option<String>,
}
#[derive(Serialize)]
pub(crate) struct DomainList {
pub(crate) domain: String,

View File

@@ -0,0 +1,314 @@
use super::{models::*, AppState, helpers::validate_ip};
use crate::auth::Claims;
use gurt::prelude::*;
use std::{env, collections::HashMap};
fn parse_query_string(query: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
params.insert(key.to_string(), value.to_string());
}
}
params
}
pub(crate) async fn index(_app_state: AppState) -> Result<GurtResponse> {
let body = format!(
"GurtDNS v{}!\n\nThe available endpoints are:\n\n - [GET] /domains\n - [GET] /domain/{{name}}/{{tld}}\n - [POST] /domain\n - [PUT] /domain/{{key}}\n - [DELETE] /domain/{{key}}\n - [GET] /tlds\n\nRatelimits are as follows: 5 requests per 10 minutes on `[POST] /domain`.\n\nCode link: https://github.com/outpoot/gurted",
env!("CARGO_PKG_VERSION")
);
Ok(GurtResponse::ok().with_string_body(body))
}
pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -> Result<Domain> {
validate_ip(&domain)?;
if !app.config.tld_list().contains(&domain.tld.as_str())
|| !domain.name.chars().all(|c| c.is_alphabetic() || c == '-')
|| domain.name.len() > 24
|| domain.name.is_empty()
|| domain.name.starts_with('-')
|| domain.name.ends_with('-') {
return Err(GurtError::invalid_message("Invalid name, non-existent TLD, or name too long (24 chars)."));
}
if app.config.offen_words().iter().any(|word| domain.name.contains(word)) {
return Err(GurtError::invalid_message("The given domain name is offensive."));
}
let existing_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
)
.bind(&domain.name)
.bind(&domain.tld)
.fetch_one(&app.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if existing_count > 0 {
return Err(GurtError::invalid_message("Domain already exists"));
}
sqlx::query(
"INSERT INTO domains (name, tld, ip, user_id, status) VALUES ($1, $2, $3, $4, 'pending')"
)
.bind(&domain.name)
.bind(&domain.tld)
.bind(&domain.ip)
.bind(user_id)
.execute(&app.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to create domain"))?;
// Decrease user's registrations remaining
sqlx::query("UPDATE users SET registrations_remaining = registrations_remaining - 1 WHERE id = $1")
.bind(user_id)
.execute(&app.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to update user registrations"))?;
Ok(domain)
}
pub(crate) async fn create_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
// Check if user has registrations remaining
let user: (i32,) = sqlx::query_as("SELECT registrations_remaining FROM users WHERE id = $1")
.bind(claims.user_id)
.fetch_one(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("User not found"))?;
if user.0 <= 0 {
return Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "Failed to create domain",
error: "No registrations remaining".into(),
})?);
}
let domain: Domain = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
match create_logic(domain.clone(), claims.user_id, &app_state).await {
Ok(created_domain) => {
Ok(GurtResponse::ok().with_json_body(&created_domain)?)
}
Err(e) => {
Ok(GurtResponse::bad_request().with_json_body(&Error {
msg: "Failed to create domain",
error: e.to_string(),
})?)
}
}
}
pub(crate) async fn get_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 4 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
}
let name = path_parts[2];
let tld = path_parts[3];
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
match domain {
Some(domain) => {
let response_domain = ResponseDomain {
name: domain.name,
tld: domain.tld,
ip: domain.ip,
records: None, // TODO: Implement DNS records
};
Ok(GurtResponse::ok().with_json_body(&response_domain)?)
}
None => Ok(GurtResponse::not_found().with_string_body("Domain not found"))
}
}
pub(crate) async fn get_domains(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
// Parse pagination from query parameters
let path = ctx.path();
let query_params = if let Some(query_start) = path.find('?') {
let query_string = &path[query_start + 1..];
parse_query_string(query_string)
} else {
HashMap::new()
};
let page = query_params.get("page")
.and_then(|p| p.parse::<u32>().ok())
.unwrap_or(1)
.max(1); // Ensure page is at least 1
let page_size = query_params.get("limit")
.and_then(|l| l.parse::<u32>().ok())
.unwrap_or(100)
.clamp(1, 1000); // Limit between 1 and 1000
let offset = (page - 1) * page_size;
let domains: Vec<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE status = 'approved' ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)
.bind(page_size as i64)
.bind(offset as i64)
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let response_domains: Vec<ResponseDomain> = domains.into_iter().map(|domain| {
ResponseDomain {
name: domain.name,
tld: domain.tld,
ip: domain.ip,
records: None,
}
}).collect();
let response = PaginationResponse {
domains: response_domains,
page,
limit: page_size,
};
Ok(GurtResponse::ok().with_json_body(&response)?)
}
pub(crate) async fn get_tlds(app_state: AppState) -> Result<GurtResponse> {
Ok(GurtResponse::ok().with_json_body(&app_state.config.tld_list())?)
}
pub(crate) async fn check_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let path = ctx.path();
let query_params = if let Some(query_start) = path.find('?') {
let query_string = &path[query_start + 1..];
parse_query_string(query_string)
} else {
return Ok(GurtResponse::bad_request().with_string_body("Missing query parameters. Expected ?name=<name>&tld=<tld>"));
};
let name = query_params.get("name")
.ok_or_else(|| GurtError::invalid_message("Missing 'name' parameter"))?;
let tld = query_params.get("tld")
.ok_or_else(|| GurtError::invalid_message("Missing 'tld' parameter"))?;
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2"
)
.bind(name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let domain_list = DomainList {
domain: format!("{}.{}", name, tld),
taken: domain.is_some(),
};
Ok(GurtResponse::ok().with_json_body(&domain_list)?)
}
pub(crate) async fn update_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 4 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
}
let name = path_parts[2];
let tld = path_parts[3];
let update_data: UpdateDomain = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
// Verify user owns this domain
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
)
.bind(name)
.bind(tld)
.bind(claims.user_id)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let domain = match domain {
Some(d) => d,
None => return Ok(GurtResponse::not_found().with_string_body("Domain not found or access denied"))
};
// Validate IP
validate_ip(&Domain {
id: domain.id,
name: domain.name.clone(),
tld: domain.tld.clone(),
ip: update_data.ip.clone(),
user_id: domain.user_id,
status: domain.status,
denial_reason: domain.denial_reason,
created_at: domain.created_at,
})?;
sqlx::query("UPDATE domains SET ip = $1 WHERE name = $2 AND tld = $3 AND user_id = $4")
.bind(&update_data.ip)
.bind(name)
.bind(tld)
.bind(claims.user_id)
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to update domain"))?;
Ok(GurtResponse::ok().with_string_body("Domain updated successfully"))
}
pub(crate) async fn delete_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 4 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
}
let name = path_parts[2];
let tld = path_parts[3];
// Verify user owns this domain
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
)
.bind(name)
.bind(tld)
.bind(claims.user_id)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if domain.is_none() {
return Ok(GurtResponse::not_found().with_string_body("Domain not found or access denied"));
}
sqlx::query("DELETE FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3")
.bind(name)
.bind(tld)
.bind(claims.user_id)
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to delete domain"))?;
Ok(GurtResponse::ok().with_string_body("Domain deleted successfully"))
}
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,
error: String,
}

View File

@@ -1,94 +0,0 @@
mod auth_routes;
mod helpers;
mod models;
mod ratelimit;
mod routes;
use crate::{auth::jwt_middleware, config::Config, discord_bot};
use actix_governor::{Governor, GovernorConfigBuilder};
use actix_web::{http::Method, web, web::Data, App, HttpServer};
use actix_web_httpauth::middleware::HttpAuthentication;
use colored::Colorize;
use macros_rs::fmt::{crashln, string};
use ratelimit::RealIpKeyExtractor;
use std::{net::IpAddr, str::FromStr, time::Duration};
// Domain struct is now defined in models.rs
#[derive(Clone)]
pub(crate) struct AppState {
trusted: IpAddr,
config: Config,
db: sqlx::PgPool,
}
#[actix_web::main]
pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
let config = Config::new().set_path(&cli.config).read();
let trusted_ip = match IpAddr::from_str(&config.server.address) {
Ok(addr) => addr,
Err(err) => crashln!("Cannot parse address.\n{}", string!(err).white()),
};
let governor_builder = GovernorConfigBuilder::default()
.methods(vec![Method::POST])
.period(Duration::from_secs(600))
.burst_size(5)
.key_extractor(RealIpKeyExtractor)
.finish()
.unwrap();
let db = match config.connect_to_db().await {
Ok(pool) => pool,
Err(err) => crashln!("Failed to connect to PostgreSQL database.\n{}", string!(err).white()),
};
// Start Discord bot
if !config.discord.bot_token.is_empty() {
if let Err(e) = discord_bot::start_discord_bot(config.discord.bot_token.clone(), db.clone()).await {
log::error!("Failed to start Discord bot: {}", e);
}
}
let auth_middleware = HttpAuthentication::bearer(jwt_middleware);
let jwt_secret = config.auth.jwt_secret.clone();
let app = move || {
let data = AppState {
db: db.clone(),
trusted: trusted_ip,
config: Config::new().set_path(&cli.config).read(),
};
App::new()
.app_data(Data::new(data))
.app_data(Data::new(jwt_secret.clone()))
// Public routes
.service(routes::index)
.service(routes::get_domain)
.service(routes::get_domains)
.service(routes::get_tlds)
.service(routes::check_domain)
// Auth routes
.service(auth_routes::register)
.service(auth_routes::login)
// Protected routes
.service(
web::scope("")
.wrap(auth_middleware.clone())
.service(auth_routes::get_user_info)
.service(auth_routes::create_invite)
.service(auth_routes::redeem_invite)
.service(auth_routes::create_domain_invite)
.service(auth_routes::redeem_domain_invite)
.service(routes::update_domain)
.service(routes::delete_domain)
.route("/domain", web::post().to(routes::create_domain).wrap(Governor::new(&governor_builder)))
)
};
log::info!("Listening on {}", config.get_address());
HttpServer::new(app).bind(config.get_address())?.run().await
}

View File

@@ -1,547 +0,0 @@
use super::{models::*, AppState};
use crate::auth::*;
use actix_web::{web, HttpResponse, Responder, HttpRequest, HttpMessage};
use sqlx::Row;
use rand::Rng;
use chrono::Utc;
#[actix_web::post("/auth/register")]
pub(crate) async fn register(
user: web::Json<RegisterRequest>,
app: web::Data<AppState>
) -> impl Responder {
let registrations = 3; // New users get 3 registrations by default
// Hash password
let password_hash = match hash_password(&user.password) {
Ok(hash) => hash,
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to hash password",
error: "HASH_ERROR".into(),
});
}
};
// Create user
let user_result = sqlx::query(
"INSERT INTO users (username, password_hash, registrations_remaining, domain_invite_codes) VALUES ($1, $2, $3, $4) RETURNING id"
)
.bind(&user.username)
.bind(&password_hash)
.bind(registrations)
.bind(3) // Default 3 domain invite codes
.fetch_one(&app.db)
.await;
match user_result {
Ok(row) => {
let user_id: i32 = row.get("id");
// Generate JWT
match generate_jwt(user_id, &user.username, &app.config.auth.jwt_secret) {
Ok(token) => {
HttpResponse::Ok().json(LoginResponse {
token,
user: UserInfo {
id: user_id,
username: user.username.clone(),
registrations_remaining: registrations,
domain_invite_codes: 3,
created_at: Utc::now(),
},
})
}
Err(_) => HttpResponse::InternalServerError().json(Error {
msg: "Failed to generate token",
error: "TOKEN_ERROR".into(),
}),
}
}
Err(sqlx::Error::Database(db_err)) => {
if db_err.is_unique_violation() {
HttpResponse::Conflict().json(Error {
msg: "Username already exists",
error: "USER_EXISTS".into(),
})
} else {
HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
})
}
}
Err(_) => HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
}),
}
}
#[actix_web::post("/auth/login")]
pub(crate) async fn login(
credentials: web::Json<LoginRequest>,
app: web::Data<AppState>
) -> impl Responder {
match sqlx::query_as::<_, User>(
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE username = $1"
)
.bind(&credentials.username)
.fetch_optional(&app.db)
.await
{
Ok(Some(user)) => {
match verify_password(&credentials.password, &user.password_hash) {
Ok(true) => {
match generate_jwt(user.id, &user.username, &app.config.auth.jwt_secret) {
Ok(token) => {
HttpResponse::Ok().json(LoginResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
registrations_remaining: user.registrations_remaining,
domain_invite_codes: user.domain_invite_codes,
created_at: user.created_at,
},
})
}
Err(e) => {
eprintln!("JWT generation error: {:?}", e);
HttpResponse::InternalServerError().json(Error {
msg: "Failed to generate token",
error: "TOKEN_ERROR".into(),
})
},
}
}
Ok(false) | Err(_) => {
HttpResponse::Unauthorized().json(Error {
msg: "Invalid credentials",
error: "INVALID_CREDENTIALS".into(),
})
}
}
}
Ok(None) => {
HttpResponse::Unauthorized().json(Error {
msg: "Invalid credentials",
error: "INVALID_CREDENTIALS".into(),
})
}
Err(e) => {
eprintln!("Database error: {:?}", e);
HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
})
},
}
}
#[actix_web::get("/auth/me")]
pub(crate) async fn get_user_info(
req: HttpRequest,
app: web::Data<AppState>
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
match sqlx::query_as::<_, User>(
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE id = $1"
)
.bind(claims.user_id)
.fetch_optional(&app.db)
.await
{
Ok(Some(user)) => {
HttpResponse::Ok().json(UserInfo {
id: user.id,
username: user.username,
registrations_remaining: user.registrations_remaining,
domain_invite_codes: user.domain_invite_codes,
created_at: user.created_at,
})
}
Ok(None) => HttpResponse::NotFound().json(Error {
msg: "User not found",
error: "USER_NOT_FOUND".into(),
}),
Err(_) => HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
}),
}
}
#[actix_web::post("/auth/invite")]
pub(crate) async fn create_invite(
req: HttpRequest,
app: web::Data<AppState>
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
// Generate random invite code
let invite_code: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(16)
.map(char::from)
.collect();
// Create invite code (no registration cost)
match sqlx::query(
"INSERT INTO invite_codes (code, created_by) VALUES ($1, $2)"
)
.bind(&invite_code)
.bind(claims.user_id)
.execute(&app.db)
.await
{
Ok(_) => {},
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to create invite code",
error: "DB_ERROR".into(),
});
}
}
HttpResponse::Ok().json(serde_json::json!({
"invite_code": invite_code
}))
}
#[actix_web::post("/auth/redeem-invite")]
pub(crate) async fn redeem_invite(
invite_request: web::Json<serde_json::Value>,
req: HttpRequest,
app: web::Data<AppState>
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
let invite_code = match invite_request.get("invite_code").and_then(|v| v.as_str()) {
Some(code) => code,
None => {
return HttpResponse::BadRequest().json(Error {
msg: "Invite code is required",
error: "INVITE_CODE_REQUIRED".into(),
});
}
};
// Find and validate invite code
let invite = match sqlx::query_as::<_, InviteCode>(
"SELECT id, code, created_by, used_by, created_at, used_at FROM invite_codes WHERE code = $1 AND used_by IS NULL"
)
.bind(invite_code)
.fetch_optional(&app.db)
.await
{
Ok(Some(invite)) => invite,
Ok(None) => {
return HttpResponse::BadRequest().json(Error {
msg: "Invalid or already used invite code",
error: "INVALID_INVITE".into(),
});
}
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Start transaction to redeem invite
let mut tx = match app.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Mark invite as used
if let Err(_) = sqlx::query(
"UPDATE invite_codes SET used_by = $1, used_at = CURRENT_TIMESTAMP WHERE id = $2"
)
.bind(claims.user_id)
.bind(invite.id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to redeem invite code",
error: "DB_ERROR".into(),
});
}
// Add registrations to user (3 registrations per invite)
if let Err(_) = sqlx::query(
"UPDATE users SET registrations_remaining = registrations_remaining + 3 WHERE id = $1"
)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to add registrations",
error: "DB_ERROR".into(),
});
}
if let Err(_) = tx.commit().await {
return HttpResponse::InternalServerError().json(Error {
msg: "Transaction failed",
error: "DB_ERROR".into(),
});
}
HttpResponse::Ok().json(serde_json::json!({
"message": "Invite code redeemed successfully",
"registrations_added": 3
}))
}
#[actix_web::post("/auth/domain-invite")]
pub(crate) async fn create_domain_invite(
req: HttpRequest,
app: web::Data<AppState>
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
// Check if user has domain invite codes remaining
let user = match sqlx::query_as::<_, User>(
"SELECT id, username, password_hash, registrations_remaining, domain_invite_codes, created_at FROM users WHERE id = $1"
)
.bind(claims.user_id)
.fetch_optional(&app.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => {
return HttpResponse::NotFound().json(Error {
msg: "User not found",
error: "USER_NOT_FOUND".into(),
});
}
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
if user.domain_invite_codes <= 0 {
return HttpResponse::BadRequest().json(Error {
msg: "No domain invite codes remaining",
error: "NO_DOMAIN_INVITES".into(),
});
}
// Generate random domain invite code
let invite_code: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(16)
.map(char::from)
.collect();
// Start transaction
let mut tx = match app.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Create domain invite code
if let Err(_) = sqlx::query(
"INSERT INTO domain_invite_codes (code, created_by) VALUES ($1, $2)"
)
.bind(&invite_code)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to create domain invite code",
error: "DB_ERROR".into(),
});
}
// Decrease user's domain invite codes
if let Err(_) = sqlx::query(
"UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1"
)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to update domain invite codes",
error: "DB_ERROR".into(),
});
}
if let Err(_) = tx.commit().await {
return HttpResponse::InternalServerError().json(Error {
msg: "Transaction failed",
error: "DB_ERROR".into(),
});
}
HttpResponse::Ok().json(serde_json::json!({
"domain_invite_code": invite_code
}))
}
#[actix_web::post("/auth/redeem-domain-invite")]
pub(crate) async fn redeem_domain_invite(
invite_request: web::Json<serde_json::Value>,
req: HttpRequest,
app: web::Data<AppState>
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
let invite_code = match invite_request.get("domain_invite_code").and_then(|v| v.as_str()) {
Some(code) => code,
None => {
return HttpResponse::BadRequest().json(Error {
msg: "Domain invite code is required",
error: "DOMAIN_INVITE_CODE_REQUIRED".into(),
});
}
};
// Find and validate domain invite code
let invite = match sqlx::query_as::<_, DomainInviteCode>(
"SELECT id, code, created_by, used_by, created_at, used_at FROM domain_invite_codes WHERE code = $1 AND used_by IS NULL"
)
.bind(invite_code)
.fetch_optional(&app.db)
.await
{
Ok(Some(invite)) => invite,
Ok(None) => {
return HttpResponse::BadRequest().json(Error {
msg: "Invalid or already used domain invite code",
error: "INVALID_DOMAIN_INVITE".into(),
});
}
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Start transaction to redeem invite
let mut tx = match app.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Mark domain invite as used
if let Err(_) = sqlx::query(
"UPDATE domain_invite_codes SET used_by = $1, used_at = CURRENT_TIMESTAMP WHERE id = $2"
)
.bind(claims.user_id)
.bind(invite.id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to redeem domain invite code",
error: "DB_ERROR".into(),
});
}
// Add domain invite codes to user (1 per domain invite)
if let Err(_) = sqlx::query(
"UPDATE users SET domain_invite_codes = domain_invite_codes + 1 WHERE id = $1"
)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to add domain invite codes",
error: "DB_ERROR".into(),
});
}
if let Err(_) = tx.commit().await {
return HttpResponse::InternalServerError().json(Error {
msg: "Transaction failed",
error: "DB_ERROR".into(),
});
}
HttpResponse::Ok().json(serde_json::json!({
"message": "Domain invite code redeemed successfully",
"domain_invite_codes_added": 1
}))
}

View File

@@ -1,72 +0,0 @@
use super::{models::*, AppState};
use actix_web::{web::Data, HttpResponse};
use regex::Regex;
use serde::Deserialize;
use std::net::{Ipv4Addr, Ipv6Addr};
pub fn validate_ip(domain: &Domain) -> Result<(), HttpResponse> {
let valid_url = Regex::new(r"(?i)\bhttps?://[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]").unwrap();
let is_valid_ip = domain.ip.parse::<Ipv4Addr>().is_ok() || domain.ip.parse::<Ipv6Addr>().is_ok();
let is_valid_url = valid_url.is_match(&domain.ip);
if is_valid_ip || is_valid_url {
if domain.name.len() <= 100 {
Ok(())
} else {
Err(HttpResponse::BadRequest().json(Error {
msg: "Failed to create domain",
error: "Invalid name, non-existent TLD, or name too long (100 chars).".into(),
}))
}
} else {
Err(HttpResponse::BadRequest().json(Error {
msg: "Failed to create domain",
error: "Invalid name, non-existent TLD, or name too long (100 chars).".into(),
}))
}
}
pub fn deserialize_lowercase<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(s.to_lowercase())
}
pub async fn is_domain_taken(name: &str, tld: Option<&str>, app: Data<AppState>) -> Vec<DomainList> {
if let Some(tld) = tld {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
)
.bind(name)
.bind(tld)
.fetch_one(&app.db)
.await
.unwrap_or(0);
vec![DomainList {
taken: count > 0,
domain: format!("{}.{}", name, tld),
}]
} else {
let mut result = Vec::new();
for tld in &*app.config.tld_list() {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
)
.bind(name)
.bind(tld)
.fetch_one(&app.db)
.await
.unwrap_or(0);
result.push(DomainList {
taken: count > 0,
domain: format!("{}.{}", name, tld),
});
}
result
}
}

View File

@@ -1,61 +0,0 @@
use super::models::Ratelimit;
use actix_web::{dev::ServiceRequest, web, HttpResponse, HttpResponseBuilder};
use std::{
net::{IpAddr, SocketAddr},
str::FromStr,
time::{SystemTime, UNIX_EPOCH},
};
use actix_governor::{
governor::clock::{Clock, DefaultClock, QuantaInstant},
governor::NotUntil,
KeyExtractor, SimpleKeyExtractionError,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RealIpKeyExtractor;
impl KeyExtractor for RealIpKeyExtractor {
type Key = IpAddr;
type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
let reverse_proxy_ip = req
.app_data::<web::Data<super::AppState>>()
.map(|ip| ip.get_ref().trusted.to_owned())
.unwrap_or_else(|| IpAddr::from_str("0.0.0.0").unwrap());
let peer_ip = req.peer_addr().map(|socket| socket.ip());
let connection_info = req.connection_info();
match peer_ip {
Some(peer) if peer == reverse_proxy_ip => connection_info
.realip_remote_addr()
.ok_or_else(|| SimpleKeyExtractionError::new("Could not extract real IP address from request"))
.and_then(|str| {
SocketAddr::from_str(str)
.map(|socket| socket.ip())
.or_else(|_| IpAddr::from_str(str))
.map_err(|_| SimpleKeyExtractionError::new("Could not extract real IP address from request"))
}),
_ => connection_info
.peer_addr()
.ok_or_else(|| SimpleKeyExtractionError::new("Could not extract peer IP address from request"))
.and_then(|str| SocketAddr::from_str(str).map_err(|_| SimpleKeyExtractionError::new("Could not extract peer IP address from request")))
.map(|socket| socket.ip()),
}
}
fn exceed_rate_limit_response(&self, negative: &NotUntil<QuantaInstant>, mut response: HttpResponseBuilder) -> HttpResponse {
let current_unix_timestamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs();
let wait_time = negative.wait_time_from(DefaultClock::default().now()).as_secs();
let wait_time_unix = current_unix_timestamp + negative.wait_time_from(DefaultClock::default().now()).as_secs();
response.json(Ratelimit {
after: wait_time_unix,
error: "ratelimited_endpoint",
msg: format!("Too many requests, try again in {wait_time}s"),
})
}
}

View File

@@ -1,400 +0,0 @@
use super::{models::*, AppState};
use crate::{auth::Claims, discord_bot::*, http::helpers};
use std::env;
use actix_web::{
web::{self, Data},
HttpRequest, HttpResponse, Responder, HttpMessage,
};
#[actix_web::get("/")]
pub(crate) async fn index() -> impl Responder {
HttpResponse::Ok().body(format!(
"GurtDNS v{}!\n\nThe available endpoints are:\n\n - [GET] /domains\n - [GET] /domain/{{name}}/{{tld}}\n - [POST] /domain\n - [PUT] /domain/{{key}}\n - [DELETE] /domain/{{key}}\n - [GET] /tlds\n\nRatelimits are as follows: 5 requests per 10 minutes on `[POST] /domain`.\n\nCode link: https://github.com/outpoot/gurted",env!("CARGO_PKG_VERSION")),
)
}
pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -> Result<Domain, HttpResponse> {
helpers::validate_ip(&domain)?;
if !app.config.tld_list().contains(&domain.tld.as_str()) || !domain.name.chars().all(|c| c.is_alphabetic() || c == '-') || domain.name.len() > 24 {
return Err(HttpResponse::BadRequest().json(Error {
msg: "Failed to create domain",
error: "Invalid name, non-existent TLD, or name too long (24 chars).".into(),
}));
}
if app.config.offen_words().iter().any(|word| domain.name.contains(word)) {
return Err(HttpResponse::BadRequest().json(Error {
msg: "Failed to create domain",
error: "The given domain name is offensive.".into(),
}));
}
let existing_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM domains WHERE name = ? AND tld = ?"
)
.bind(&domain.name)
.bind(&domain.tld)
.fetch_one(&app.db)
.await
.map_err(|_| HttpResponse::InternalServerError().finish())?;
if existing_count > 0 {
return Err(HttpResponse::Conflict().finish());
}
sqlx::query(
"INSERT INTO domains (name, tld, ip, user_id, status) VALUES ($1, $2, $3, $4, 'pending')"
)
.bind(&domain.name)
.bind(&domain.tld)
.bind(&domain.ip)
.bind(user_id)
.execute(&app.db)
.await
.map_err(|_| HttpResponse::Conflict().finish())?;
Ok(domain)
}
pub(crate) async fn create_domain(
domain: web::Json<Domain>,
app: Data<AppState>,
req: HttpRequest
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
// Check if user has registrations or domain invite codes remaining
let (user_registrations, user_domain_invites): (i32, i32) = match sqlx::query_as::<_, (i32, i32)>(
"SELECT registrations_remaining, domain_invite_codes FROM users WHERE id = $1"
)
.bind(claims.user_id)
.fetch_one(&app.db)
.await
{
Ok((registrations, domain_invites)) => (registrations, domain_invites),
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
if user_registrations <= 0 && user_domain_invites <= 0 {
return HttpResponse::BadRequest().json(Error {
msg: "No domain registrations or domain invite codes remaining",
error: "NO_REGISTRATIONS_OR_INVITES".into(),
});
}
let domain = domain.into_inner();
match create_logic(domain.clone(), claims.user_id, app.as_ref()).await {
Ok(_) => {
// Start transaction for domain registration
let mut tx = match app.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return HttpResponse::InternalServerError().json(Error {
msg: "Database error",
error: "DB_ERROR".into(),
});
}
};
// Get the created domain ID
let domain_id: i32 = match sqlx::query_scalar(
"SELECT id FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3 ORDER BY created_at DESC LIMIT 1"
)
.bind(&domain.name)
.bind(&domain.tld)
.bind(claims.user_id)
.fetch_one(&mut *tx)
.await
{
Ok(id) => id,
Err(_) => {
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to get domain ID",
error: "DB_ERROR".into(),
});
}
};
// Get user's current domain invite codes
let user_domain_invites: i32 = match sqlx::query_scalar(
"SELECT domain_invite_codes FROM users WHERE id = $1"
)
.bind(claims.user_id)
.fetch_one(&mut *tx)
.await
{
Ok(invites) => invites,
Err(_) => {
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Database error getting user domain invites",
error: "DB_ERROR".into(),
});
}
};
// Auto-consume domain invite code if available, otherwise use registration
if user_domain_invites > 0 {
// Use domain invite code
if let Err(_) = sqlx::query(
"UPDATE users SET domain_invite_codes = domain_invite_codes - 1 WHERE id = $1"
)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to consume domain invite code",
error: "DB_ERROR".into(),
});
}
} else {
// Use regular registration
if let Err(_) = sqlx::query(
"UPDATE users SET registrations_remaining = registrations_remaining - 1 WHERE id = $1"
)
.bind(claims.user_id)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return HttpResponse::InternalServerError().json(Error {
msg: "Failed to consume registration",
error: "DB_ERROR".into(),
});
}
}
// Commit the transaction
if let Err(_) = tx.commit().await {
return HttpResponse::InternalServerError().json(Error {
msg: "Transaction failed",
error: "DB_ERROR".into(),
});
}
// Send to Discord for approval
let registration = DomainRegistration {
id: domain_id,
domain_name: domain.name.clone(),
tld: domain.tld.clone(),
ip: domain.ip.clone(),
user_id: claims.user_id,
username: claims.username.clone(),
};
let bot_token = app.config.discord.bot_token.clone();
let channel_id = app.config.discord.channel_id;
tokio::spawn(async move {
if let Err(e) = send_domain_approval_request(
channel_id,
registration,
&bot_token,
).await {
log::error!("Failed to send Discord message: {}", e);
}
});
HttpResponse::Ok().json(serde_json::json!({
"message": "Domain registration submitted for approval",
"domain": format!("{}.{}", domain.name, domain.tld),
"status": "pending"
}))
}
Err(error) => error,
}
}
#[actix_web::get("/domain/{name}/{tld}")]
pub(crate) async fn get_domain(path: web::Path<(String, String)>, app: Data<AppState>) -> impl Responder {
let (name, tld) = path.into_inner();
match sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(&name)
.bind(&tld)
.fetch_optional(&app.db)
.await
{
Ok(Some(domain)) => HttpResponse::Ok().json(ResponseDomain {
tld: domain.tld,
name: domain.name,
ip: domain.ip,
records: None,
}),
Ok(None) => HttpResponse::NotFound().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::put("/domain/{name}/{tld}")]
pub(crate) async fn update_domain(
path: web::Path<(String, String)>,
domain_update: web::Json<UpdateDomain>,
app: Data<AppState>,
req: HttpRequest
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
let (name, tld) = path.into_inner();
match sqlx::query(
"UPDATE domains SET ip = $1 WHERE name = $2 AND tld = $3 AND user_id = $4 AND status = 'approved'"
)
.bind(&domain_update.ip)
.bind(&name)
.bind(&tld)
.bind(claims.user_id)
.execute(&app.db)
.await
{
Ok(result) => {
if result.rows_affected() == 1 {
HttpResponse::Ok().json(domain_update.into_inner())
} else {
HttpResponse::NotFound().json(Error {
msg: "Domain not found or not owned by user",
error: "DOMAIN_NOT_FOUND".into(),
})
}
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::delete("/domain/{name}/{tld}")]
pub(crate) async fn delete_domain(
path: web::Path<(String, String)>,
app: Data<AppState>,
req: HttpRequest
) -> impl Responder {
let extensions = req.extensions();
let claims = match extensions.get::<Claims>() {
Some(claims) => claims,
None => {
return HttpResponse::Unauthorized().json(Error {
msg: "Authentication required",
error: "AUTH_REQUIRED".into(),
});
}
};
let (name, tld) = path.into_inner();
match sqlx::query(
"DELETE FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
)
.bind(&name)
.bind(&tld)
.bind(claims.user_id)
.execute(&app.db)
.await
{
Ok(result) => {
if result.rows_affected() == 1 {
HttpResponse::Ok().finish()
} else {
HttpResponse::NotFound().json(Error {
msg: "Domain not found or not owned by user",
error: "DOMAIN_NOT_FOUND".into(),
})
}
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::post("/domain/check")]
pub(crate) async fn check_domain(query: web::Json<DomainQuery>, app: Data<AppState>) -> impl Responder {
let DomainQuery { name, tld } = query.into_inner();
let result = helpers::is_domain_taken(&name, tld.as_deref(), app).await;
HttpResponse::Ok().json(result)
}
#[actix_web::get("/domains")]
pub(crate) async fn get_domains(query: web::Query<PaginationParams>, app: Data<AppState>) -> impl Responder {
let page = query.page.unwrap_or(1);
let limit = query.page_size.unwrap_or(15);
if page == 0 || limit == 0 {
return HttpResponse::BadRequest().json(Error {
msg: "page_size or page must be greater than 0",
error: "Invalid pagination parameters".into(),
});
}
if limit > 100 {
return HttpResponse::BadRequest().json(Error {
msg: "page_size must be greater than 0 and less than or equal to 100",
error: "Invalid pagination parameters".into(),
});
}
let offset = (page - 1) * limit;
match sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE status = 'approved' ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&app.db)
.await
{
Ok(domains) => {
let response_domains: Vec<ResponseDomain> = domains
.into_iter()
.map(|domain| ResponseDomain {
tld: domain.tld,
name: domain.name,
ip: domain.ip,
records: None,
})
.collect();
HttpResponse::Ok().json(PaginationResponse {
domains: response_domains,
page,
limit,
})
}
Err(err) => HttpResponse::InternalServerError().json(Error {
msg: "Failed to fetch domains",
error: err.to_string(),
}),
}
}
#[actix_web::get("/tlds")]
pub(crate) async fn get_tlds(app: Data<AppState>) -> impl Responder { HttpResponse::Ok().json(&*app.config.tld_list()) }

View File

@@ -1,5 +1,5 @@
mod config;
mod http;
mod gurt_server;
mod secret;
mod auth;
mod discord_bot;
@@ -27,12 +27,11 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Start the daemon
Start,
}
fn main() {
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let mut env = pretty_env_logger::formatted_builder();
let level = cli.verbose.log_level_filter();
@@ -47,7 +46,7 @@ fn main() {
match &cli.command {
Commands::Start => {
if let Err(err) = http::start(cli) {
if let Err(err) = gurt_server::start(cli).await {
log::error!("Failed to start server: {err}")
}
}