DNS backend
This commit is contained in:
2
dns/.gitignore
vendored
Normal file
2
dns/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
config.toml
|
||||
target
|
||||
3688
dns/Cargo.lock
generated
Normal file
3688
dns/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
dns/Cargo.toml
Normal file
30
dns/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "webx_dns"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.21"
|
||||
toml = "0.8.13"
|
||||
regex = "1.10.4"
|
||||
jsonwebtoken = "9.2"
|
||||
bcrypt = "0.15"
|
||||
serenity = { version = "0.12", features = ["client", "gateway", "rustls_backend", "model"] }
|
||||
actix-web-httpauth = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
colored = "2.1.0"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "json"] }
|
||||
anyhow = "1.0.86"
|
||||
futures = "0.3.30"
|
||||
actix-web = "4.6.0"
|
||||
macros-rs = "1.2.1"
|
||||
prettytable = "0.10.0"
|
||||
actix-governor = "0.5.0"
|
||||
pretty_env_logger = "0.5.0"
|
||||
clap-verbosity-flag = "2.2.0"
|
||||
|
||||
tokio = { version = "1.38.0", features = ["full"] }
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
rand = { version = "0.8.5", features = ["small_rng"] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
300
dns/README.md
Normal file
300
dns/README.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Domain Management API
|
||||
|
||||
This is a Domain Management API built with Rust (Actix Web) and PostgreSQL. It provides user authentication, domain registration with Discord approval workflow, and invite-based registration limits.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **JWT Authentication** - Secure user registration and login
|
||||
- 📝 **Domain Registration** - Submit domains for approval with usage limits
|
||||
- 🤖 **Discord Integration** - Automatic approval workflow via Discord bot
|
||||
- 📧 **Invite System** - Users can share registration slots via invite codes
|
||||
- 🛡️ **Rate Limiting** - Protection against abuse
|
||||
- 📊 **PostgreSQL Database** - Reliable data storage with migrations
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Authentication Endpoints](#authentication-endpoints)
|
||||
- [POST /auth/register](#post-authregister)
|
||||
- [POST /auth/login](#post-authlogin)
|
||||
- [GET /auth/me](#get-authme)
|
||||
- [POST /auth/invite](#post-authinvite)
|
||||
- [POST /auth/redeem-invite](#post-authredeem-invite)
|
||||
- [Domain Endpoints](#domain-endpoints)
|
||||
- [GET /](#get-)
|
||||
- [POST /domain](#post-domain) 🔒
|
||||
- [GET /domain/:name/:tld](#get-domainnametld)
|
||||
- [PUT /domain/:name/:tld](#put-domainnametld) 🔒
|
||||
- [DELETE /domain/:name/:tld](#delete-domainnametld) 🔒
|
||||
- [GET /domains](#get-domains)
|
||||
- [GET /tlds](#get-tlds)
|
||||
- [POST /domain/check](#post-domaincheck)
|
||||
|
||||
🔒 = Requires authentication
|
||||
|
||||
## Authentication Endpoints
|
||||
|
||||
### POST /auth/register
|
||||
|
||||
Register a new user account. New users start with 3 domain registrations.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "myusername",
|
||||
"password": "mypassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token-here",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "myusername",
|
||||
"registrations_remaining": 3,
|
||||
"created_at": "2023-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /auth/login
|
||||
|
||||
Login with existing credentials.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "myusername",
|
||||
"password": "mypassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"token": "jwt-token-here",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "myusername",
|
||||
"registrations_remaining": 2,
|
||||
"created_at": "2023-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /auth/me 🔒
|
||||
|
||||
Get current user information. Requires `Authorization: Bearer <token>` header.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "myusername",
|
||||
"registrations_remaining": 2,
|
||||
"created_at": "2023-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /auth/invite 🔒
|
||||
|
||||
Create an invite code that can be redeemed for 3 additional domain registrations. Requires authentication but does NOT consume any of your registrations.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"invite_code": "abc123def456"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /auth/redeem-invite 🔒
|
||||
|
||||
Redeem an invite code to get 3 additional domain registrations. Requires authentication.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"invite_code": "abc123def456"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Invite code redeemed successfully",
|
||||
"registrations_added": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Endpoints
|
||||
|
||||
### GET /
|
||||
|
||||
Returns a simple message with the available endpoints and rate limits.
|
||||
|
||||
**Response:**
|
||||
|
||||
```
|
||||
Hello, world! The available endpoints are:
|
||||
GET /domains,
|
||||
GET /domain/{name}/{tld},
|
||||
POST /domain,
|
||||
PUT /domain/{key},
|
||||
DELETE /domain/{key},
|
||||
GET /tlds.
|
||||
Ratelimits are as follows: 10 requests per 60s.
|
||||
```
|
||||
|
||||
### POST /domain 🔒
|
||||
|
||||
Submit a domain for approval. Requires authentication and consumes one registration slot. The domain will be sent to Discord for manual approval.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"tld": "dev",
|
||||
"ip": "192.168.1.100",
|
||||
"name": "myawesome"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Domain registration submitted for approval",
|
||||
"domain": "myawesome.dev",
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized` - Missing or invalid JWT token
|
||||
- `400 Bad Request` - No registrations remaining, invalid domain, or offensive name
|
||||
- `409 Conflict` - Domain already exists
|
||||
|
||||
### GET /domain/:name/:tld
|
||||
|
||||
Fetch an approved domain by name and TLD. Only returns domains with 'approved' status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"tld": "dev",
|
||||
"name": "myawesome",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /domain/:name/:tld 🔒
|
||||
|
||||
Update the IP address of your approved domain. You can only update domains you own.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"ip": "10.0.0.50"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ip": "10.0.0.50"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /domain/:name/:tld 🔒
|
||||
|
||||
Delete your domain. You can only delete domains you own.
|
||||
|
||||
**Response:**
|
||||
- `200 OK` - Domain deleted successfully
|
||||
- `404 Not Found` - Domain not found or not owned by you
|
||||
|
||||
### GET /domains
|
||||
|
||||
Fetch all approved domains with pagination support. Only shows domains with 'approved' status.
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` (or `p`) - Page number (default: 1)
|
||||
- `page_size` (or `s`, `size`, `l`, `limit`) - Items per page (default: 15, max: 100)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"domains": [
|
||||
{
|
||||
"tld": "dev",
|
||||
"name": "myawesome",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"limit": 15
|
||||
}
|
||||
```
|
||||
|
||||
### GET /tlds
|
||||
|
||||
Get the list of allowed top-level domains.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
["mf", "btw", "fr", "yap", "dev", "scam", "zip", "root", "web", "rizz", "habibi", "sigma", "now", "it", "soy", "lol", "uwu", "ohio", "cat"]
|
||||
```
|
||||
|
||||
### POST /domain/check
|
||||
|
||||
Check if domain name(s) are available.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "myawesome",
|
||||
"tld": "dev" // Optional - if omitted, checks all TLDs
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"domain": "myawesome.dev",
|
||||
"taken": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Discord Integration
|
||||
|
||||
When a user submits a domain registration, it's automatically sent to a configured Discord channel with:
|
||||
|
||||
- 📝 Domain details (name, TLD, IP, user info)
|
||||
- ✅ **Approve** button - Marks domain as approved
|
||||
- ❌ **Deny** button - Opens modal asking for denial reason
|
||||
|
||||
Discord admins can approve or deny registrations directly from Discord.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config.template.toml` to `config.toml` and configure your settings.
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- **Domain Registration**: 5 requests per 10 minutes (per IP)
|
||||
- **General API**: No specific limits (yet)
|
||||
|
||||
## Domain Registration Limits
|
||||
|
||||
- **User Limit**: Each user has a finite number of domain registrations
|
||||
- **Usage**: Each domain submission consumes 1 registration from your account
|
||||
- **Replenishment**: Use invite codes to get more registrations (3 per invite)
|
||||
|
||||
## User Registration & Invites
|
||||
|
||||
- **Registration**: Anyone can register - no invite required
|
||||
- **New Users**: Start with 3 domain registrations automatically
|
||||
- **Invite Creation**: Any authenticated user can create invite codes (no cost)
|
||||
- **Invite Redemption**: Redeem invite codes for 3 additional domain registrations
|
||||
- **Invite Usage**: Each invite code can only be redeemed once
|
||||
35
dns/config.template.toml
Normal file
35
dns/config.template.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
# Copy this file to config.toml and update the values as needed
|
||||
|
||||
[server]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[server.database]
|
||||
url = "postgresql://username:password@localhost:5432/domains"
|
||||
|
||||
# Maximum number of database connections
|
||||
max_connections = 10
|
||||
|
||||
[settings]
|
||||
# Available top-level domains
|
||||
tld_list = [
|
||||
"mf", "btw", "fr", "yap", "dev", "scam", "zip", "root",
|
||||
"web", "rizz", "habibi", "sigma", "now", "it", "soy",
|
||||
"lol", "uwu", "ohio", "cat"
|
||||
]
|
||||
|
||||
# Words that are not allowed in domain names
|
||||
offensive_words = [
|
||||
"nigg", "sex", "porn", "igg"
|
||||
]
|
||||
|
||||
[discord]
|
||||
# Discord bot token for domain approval notifications
|
||||
bot_token = "your-discord-bot-token-here"
|
||||
|
||||
# Channel ID where domain approval messages will be sent
|
||||
channel_id = 0
|
||||
|
||||
[auth]
|
||||
# JWT secret key for authentication (change this!)
|
||||
jwt_secret = "your-very-secure-secret-key-here"
|
||||
40
dns/migrations/001_initial.sql
Normal file
40
dns/migrations/001_initial.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
registrations_remaining INTEGER DEFAULT 3,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create invite codes table
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(32) UNIQUE NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
used_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create domains table
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
tld VARCHAR(20) NOT NULL,
|
||||
ip VARCHAR(255) NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
denial_reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(name, tld)
|
||||
);
|
||||
|
||||
-- Create indexes for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_name_tld ON domains(name, tld);
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_codes_code ON invite_codes(code);
|
||||
5
dns/migrations/002_remove_email.sql
Normal file
5
dns/migrations/002_remove_email.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Remove email field from users table
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS email;
|
||||
|
||||
-- Drop email index if it exists
|
||||
DROP INDEX IF EXISTS idx_users_email;
|
||||
5
dns/migrations/003_fix_timestamp_types.sql
Normal file
5
dns/migrations/003_fix_timestamp_types.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Fix timestamp columns to use TIMESTAMPTZ instead of TIMESTAMP
|
||||
ALTER TABLE users ALTER COLUMN created_at TYPE TIMESTAMPTZ;
|
||||
ALTER TABLE invite_codes ALTER COLUMN created_at TYPE TIMESTAMPTZ;
|
||||
ALTER TABLE invite_codes ALTER COLUMN used_at TYPE TIMESTAMPTZ;
|
||||
ALTER TABLE domains ALTER COLUMN created_at TYPE TIMESTAMPTZ;
|
||||
17
dns/migrations/004_add_domain_invite_codes.sql
Normal file
17
dns/migrations/004_add_domain_invite_codes.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Add domain_invite_codes field to users table
|
||||
ALTER TABLE users ADD COLUMN domain_invite_codes INTEGER DEFAULT 3;
|
||||
|
||||
-- Create domain invite codes table for domain-specific invites
|
||||
CREATE TABLE IF NOT EXISTS domain_invite_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(32) UNIQUE NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
used_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Create indexes for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_code ON domain_invite_codes(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_created_by ON domain_invite_codes(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_used_by ON domain_invite_codes(used_by);
|
||||
95
dns/src/auth.rs
Normal file
95
dns/src/auth.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
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 jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub user_id: i32,
|
||||
pub username: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub registrations_remaining: i32,
|
||||
pub domain_invite_codes: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub fn generate_jwt(user_id: i32, username: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let expiration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() + 86400 * 7; // 7 days
|
||||
|
||||
let claims = Claims {
|
||||
user_id,
|
||||
username: username.to_string(),
|
||||
exp: expiration as usize,
|
||||
};
|
||||
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
|
||||
}
|
||||
|
||||
pub fn validate_jwt(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
|
||||
decode::<Claims>(token, &DecodingKey::from_secret(secret.as_ref()), &validation)
|
||||
.map(|token_data| token_data.claims)
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
|
||||
hash(password, DEFAULT_COST)
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> 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();
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
15
dns/src/config/file.rs
Normal file
15
dns/src/config/file.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use colored::Colorize;
|
||||
use macros_rs::fmt::{crashln, string};
|
||||
use std::fs;
|
||||
|
||||
pub fn read<T: serde::de::DeserializeOwned>(path: &String) -> T {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => crashln!("Cannot find config.\n{}", string!(err).white()),
|
||||
};
|
||||
|
||||
match toml::from_str(&contents).map_err(|err| string!(err)) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => crashln!("Cannot parse config.\n{}", err.white()),
|
||||
}
|
||||
}
|
||||
77
dns/src/config/mod.rs
Normal file
77
dns/src/config/mod.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
mod file;
|
||||
mod structs;
|
||||
|
||||
use colored::Colorize;
|
||||
use macros_rs::fmt::{crashln, string};
|
||||
use sqlx::{PgPool, Error};
|
||||
use std::fs::write;
|
||||
use structs::{Auth, Database, Discord, Server, Settings};
|
||||
|
||||
pub use structs::Config;
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
let default_offensive_words = vec!["nigg", "sex", "porn", "igg"];
|
||||
let default_tld_list = vec![
|
||||
"mf", "btw", "fr", "yap", "dev", "scam", "zip", "root", "web", "rizz", "habibi", "sigma", "now", "it", "soy", "lol", "uwu", "ohio", "cat",
|
||||
];
|
||||
|
||||
Config {
|
||||
config_path: "config.toml".into(),
|
||||
server: Server {
|
||||
address: "127.0.0.1".into(),
|
||||
port: 8080,
|
||||
database: Database {
|
||||
url: "postgresql://username:password@localhost/domains".into(),
|
||||
max_connections: 10,
|
||||
},
|
||||
},
|
||||
discord: Discord {
|
||||
bot_token: "".into(),
|
||||
channel_id: 0,
|
||||
},
|
||||
auth: Auth {
|
||||
jwt_secret: "your-secret-key-here".into(),
|
||||
},
|
||||
settings: Settings {
|
||||
tld_list: default_tld_list.iter().map(|s| s.to_string()).collect(),
|
||||
offensive_words: default_offensive_words.iter().map(|s| s.to_string()).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Self { file::read(&self.config_path) }
|
||||
pub fn get_address(&self) -> String { format!("{}:{}", self.server.address.clone(), self.server.port) }
|
||||
pub fn tld_list(&self) -> Vec<&str> { self.settings.tld_list.iter().map(AsRef::as_ref).collect::<Vec<&str>>() }
|
||||
pub fn offen_words(&self) -> Vec<&str> { self.settings.offensive_words.iter().map(AsRef::as_ref).collect::<Vec<&str>>() }
|
||||
|
||||
pub fn set_path(&mut self, config_path: &String) -> &mut Self {
|
||||
self.config_path = config_path.clone();
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn write(&self) -> &Self {
|
||||
let contents = match toml::to_string(self) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => crashln!("Cannot parse config.\n{}", string!(err).white()),
|
||||
};
|
||||
|
||||
if let Err(err) = write(&self.config_path, contents) {
|
||||
crashln!("Error writing config to {}.\n{}", self.config_path, string!(err).white())
|
||||
}
|
||||
|
||||
log::info!("Created config: {}", &self.config_path,);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub async fn connect_to_db(&self) -> Result<PgPool, Error> {
|
||||
let pool = PgPool::connect(&self.server.database.url).await?;
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
log::info!("PostgreSQL database connected");
|
||||
Ok(pool)
|
||||
}
|
||||
}
|
||||
41
dns/src/config/structs.rs
Normal file
41
dns/src/config/structs.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
#[serde(skip)]
|
||||
pub config_path: String,
|
||||
pub(crate) server: Server,
|
||||
pub(crate) settings: Settings,
|
||||
pub(crate) discord: Discord,
|
||||
pub(crate) auth: Auth,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Server {
|
||||
pub(crate) address: String,
|
||||
pub(crate) port: u64,
|
||||
pub(crate) database: Database,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Database {
|
||||
pub(crate) url: String,
|
||||
pub(crate) max_connections: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Settings {
|
||||
pub(crate) tld_list: Vec<String>,
|
||||
pub(crate) offensive_words: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Discord {
|
||||
pub(crate) bot_token: String,
|
||||
pub(crate) channel_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Auth {
|
||||
pub(crate) jwt_secret: String,
|
||||
}
|
||||
207
dns/src/discord_bot.rs
Normal file
207
dns/src/discord_bot.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use serenity::async_trait;
|
||||
use serenity::all::*;
|
||||
use serenity::prelude::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct DiscordBot {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DomainRegistration {
|
||||
pub id: i32,
|
||||
pub domain_name: String,
|
||||
pub tld: String,
|
||||
pub ip: String,
|
||||
pub user_id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub struct BotHandler {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for BotHandler {
|
||||
async fn ready(&self, _: Context, ready: Ready) {
|
||||
log::info!("Discord bot {} is connected!", ready.user.name);
|
||||
}
|
||||
|
||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||
match interaction {
|
||||
Interaction::Component(component) => {
|
||||
let custom_id = &component.data.custom_id;
|
||||
|
||||
if custom_id.starts_with("approve_") {
|
||||
let domain_id: i32 = match custom_id.strip_prefix("approve_").unwrap().parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
log::error!("Invalid domain ID in approve button");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Update domain status to approved
|
||||
match sqlx::query("UPDATE domains SET status = 'approved' WHERE id = $1")
|
||||
.bind(domain_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let response = CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("✅ Domain approved!")
|
||||
.ephemeral(true)
|
||||
);
|
||||
|
||||
if let Err(e) = component.create_response(&ctx.http, response).await {
|
||||
log::error!("Error responding to interaction: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error approving domain: {}", e);
|
||||
let response = CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("❌ Error approving domain")
|
||||
.ephemeral(true)
|
||||
);
|
||||
let _ = component.create_response(&ctx.http, response).await;
|
||||
}
|
||||
}
|
||||
} else if custom_id.starts_with("deny_") {
|
||||
let domain_id = custom_id.strip_prefix("deny_").unwrap();
|
||||
|
||||
// Create modal for denial reason
|
||||
let modal = CreateModal::new(
|
||||
format!("deny_modal_{}", domain_id),
|
||||
"Deny Domain Registration"
|
||||
)
|
||||
.components(vec![
|
||||
CreateActionRow::InputText(
|
||||
CreateInputText::new(
|
||||
InputTextStyle::Paragraph,
|
||||
"Reason",
|
||||
"reason"
|
||||
)
|
||||
.placeholder("Please provide a reason for denying this domain registration")
|
||||
.required(true)
|
||||
)
|
||||
]);
|
||||
|
||||
let response = CreateInteractionResponse::Modal(modal);
|
||||
|
||||
if let Err(e) = component.create_response(&ctx.http, response).await {
|
||||
log::error!("Error showing modal: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Interaction::Modal(modal_submit) => {
|
||||
if modal_submit.data.custom_id.starts_with("deny_modal_") {
|
||||
let domain_id: i32 = match modal_submit.data.custom_id.strip_prefix("deny_modal_").unwrap().parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
log::error!("Invalid domain ID in deny modal");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the reason from modal input
|
||||
let reason = modal_submit.data.components.get(0)
|
||||
.and_then(|row| row.components.get(0))
|
||||
.and_then(|component| {
|
||||
if let ActionRowComponent::InputText(input) = component {
|
||||
input.value.as_ref().map(|v| v.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("No reason provided");
|
||||
|
||||
// Update domain status to denied with reason
|
||||
match sqlx::query("UPDATE domains SET status = 'denied', denial_reason = $1 WHERE id = $2")
|
||||
.bind(reason)
|
||||
.bind(domain_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let response = CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("❌ Domain denied!")
|
||||
.ephemeral(true)
|
||||
);
|
||||
|
||||
if let Err(e) = modal_submit.create_response(&ctx.http, response).await {
|
||||
log::error!("Error responding to modal: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error denying domain: {}", e);
|
||||
let response = CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("❌ Error denying domain")
|
||||
.ephemeral(true)
|
||||
);
|
||||
let _ = modal_submit.create_response(&ctx.http, response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Handle other interaction types if needed
|
||||
log::debug!("Unhandled interaction type: {:?}", interaction.kind());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_domain_approval_request(
|
||||
channel_id: u64,
|
||||
registration: DomainRegistration,
|
||||
bot_token: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let http = serenity::http::Http::new(bot_token);
|
||||
|
||||
let embed = CreateEmbed::new()
|
||||
.title("New Domain Registration")
|
||||
.field("Domain", format!("{}.{}", registration.domain_name, registration.tld), true)
|
||||
.field("IP", ®istration.ip, true)
|
||||
.field("User", ®istration.username, true)
|
||||
.field("User ID", registration.user_id.to_string(), true)
|
||||
.color(0x00ff00);
|
||||
|
||||
let approve_button = CreateButton::new(format!("approve_{}", registration.id))
|
||||
.style(ButtonStyle::Success)
|
||||
.label("✅ Approve");
|
||||
|
||||
let deny_button = CreateButton::new(format!("deny_{}", registration.id))
|
||||
.style(ButtonStyle::Danger)
|
||||
.label("❌ Deny");
|
||||
|
||||
let action_row = CreateActionRow::Buttons(vec![approve_button, deny_button]);
|
||||
|
||||
let message = CreateMessage::new()
|
||||
.embed(embed)
|
||||
.components(vec![action_row]);
|
||||
|
||||
let channel_id = ChannelId::new(channel_id);
|
||||
channel_id.send_message(&http, message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_discord_bot(token: String, pool: PgPool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let mut client = Client::builder(&token, intents)
|
||||
.event_handler(BotHandler { pool })
|
||||
.await?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.start().await {
|
||||
log::error!("Discord bot error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
95
dns/src/http.rs
Normal file
95
dns/src/http.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
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, HttpRequest, HttpServer};
|
||||
use actix_web_httpauth::middleware::HttpAuthentication;
|
||||
use anyhow::{anyhow, Error};
|
||||
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
|
||||
}
|
||||
547
dns/src/http/auth_routes.rs
Normal file
547
dns/src/http/auth_routes.rs
Normal file
@@ -0,0 +1,547 @@
|
||||
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
|
||||
}))
|
||||
}
|
||||
72
dns/src/http/helpers.rs
Normal file
72
dns/src/http/helpers.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
105
dns/src/http/models.rs
Normal file
105
dns/src/http/models.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use super::helpers::deserialize_lowercase;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, types::chrono::{DateTime, Utc}};
|
||||
use chrono;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct Domain {
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) id: Option<i32>,
|
||||
pub(crate) ip: String,
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) tld: String,
|
||||
#[serde(deserialize_with = "deserialize_lowercase")]
|
||||
pub(crate) name: String,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) user_id: Option<i32>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) status: Option<String>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) denial_reason: Option<String>,
|
||||
#[serde(skip_deserializing)]
|
||||
pub(crate) created_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct User {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) username: String,
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) registrations_remaining: i32,
|
||||
pub(crate) domain_invite_codes: i32,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct InviteCode {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) code: String,
|
||||
pub(crate) created_by: Option<i32>,
|
||||
pub(crate) used_by: Option<i32>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
pub(crate) used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
|
||||
pub struct DomainInviteCode {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) code: String,
|
||||
pub(crate) created_by: Option<i32>,
|
||||
pub(crate) used_by: Option<i32>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
pub(crate) used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseDomain {
|
||||
pub(crate) tld: String,
|
||||
pub(crate) ip: String,
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
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>,
|
||||
pub(crate) page: u32,
|
||||
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,
|
||||
pub(crate) taken: bool,
|
||||
}
|
||||
61
dns/src/http/ratelimit.rs
Normal file
61
dns/src/http/ratelimit.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
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"),
|
||||
})
|
||||
}
|
||||
}
|
||||
398
dns/src/http/routes.rs
Normal file
398
dns/src/http/routes.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
})
|
||||
.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()) }
|
||||
55
dns/src/main.rs
Normal file
55
dns/src/main.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
mod config;
|
||||
mod http;
|
||||
mod secret;
|
||||
mod auth;
|
||||
mod discord_bot;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap_verbosity_flag::{LogLevel, Verbosity};
|
||||
use config::Config;
|
||||
use macros_rs::fs::file_exists;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
struct Info;
|
||||
impl LogLevel for Info {
|
||||
fn default() -> Option<log::Level> { Some(log::Level::Info) }
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
#[clap(flatten)]
|
||||
verbose: Verbosity<Info>,
|
||||
#[arg(global = true, short, long, default_value_t = String::from("config.toml"), help = "config path")]
|
||||
config: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Start the daemon
|
||||
Start,
|
||||
}
|
||||
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let mut env = pretty_env_logger::formatted_builder();
|
||||
let level = cli.verbose.log_level_filter();
|
||||
|
||||
env.filter_level(level).init();
|
||||
|
||||
if !file_exists!(&cli.config) {
|
||||
Config::new().set_path(&cli.config).write();
|
||||
log::warn!("Written initial config, please configure database URL");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
match &cli.command {
|
||||
Commands::Start => {
|
||||
if let Err(err) = http::start(cli) {
|
||||
log::error!("Failed to start server: {err}")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
26
dns/src/secret.rs
Normal file
26
dns/src/secret.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use rand::{rngs::StdRng, Rng, SeedableRng};
|
||||
|
||||
pub fn generate(size: usize) -> String {
|
||||
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let alphabet_len = ALPHABET.len();
|
||||
let mask = alphabet_len.next_power_of_two() - 1;
|
||||
let step = 8 * size / 5;
|
||||
|
||||
let mut id = String::with_capacity(size);
|
||||
let mut rng = StdRng::from_entropy();
|
||||
|
||||
while id.len() < size {
|
||||
let bytes: Vec<u8> = (0..step).map(|_| rng.gen::<u8>()).collect::<Vec<u8>>();
|
||||
|
||||
id.extend(
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&byte| (byte as usize) & mask)
|
||||
.filter_map(|index| ALPHABET.get(index).copied())
|
||||
.take(size - id.len())
|
||||
.map(char::from),
|
||||
);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
Reference in New Issue
Block a user