This commit is contained in:
Face
2025-08-22 17:31:54 +03:00
parent 0a38af1b66
commit 00309149d4
39 changed files with 3001 additions and 84 deletions

60
dns/Cargo.lock generated
View File

@@ -797,6 +797,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -1545,12 +1560,50 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.2",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -3082,6 +3135,9 @@ name = "uuid"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
dependencies = [
"getrandom",
]
[[package]]
name = "uwl"
@@ -3250,6 +3306,7 @@ name = "webx_dns"
version = "0.0.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"bcrypt",
"chrono",
"clap",
@@ -3260,6 +3317,7 @@ dependencies = [
"jsonwebtoken",
"log",
"macros-rs",
"openssl",
"pretty_env_logger",
"prettytable",
"rand",
@@ -3267,9 +3325,11 @@ dependencies = [
"serde",
"serde_json",
"serenity",
"sha2",
"sqlx",
"tokio",
"toml",
"uuid",
]
[[package]]

View File

@@ -26,3 +26,7 @@ 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"
sha2 = "0.10"
base64 = "0.22"
uuid = { version = "1.0", features = ["v4"] }
openssl = "0.10"

View File

@@ -116,7 +116,6 @@
<option value="AAAA">AAAA</option>
<option value="CNAME">CNAME</option>
<option value="TXT">TXT</option>
<option value="NS">NS</option>
</select>
</div>
<div style="form-group">
@@ -131,7 +130,6 @@
<span id="help-AAAA" style="hidden">Enter an IPv6 address (e.g., 2001:db8::1)</span>
<span id="help-CNAME" style="hidden">Enter a domain name (e.g., example.com)</span>
<span id="help-TXT" style="hidden">Enter any text content</span>
<span id="help-NS" style="hidden">Enter a nameserver domain (e.g., ns1.example.com)</span>
</div>
</div>
<div style="form-group">

View File

@@ -131,7 +131,7 @@ renderRecords = function(appendOnly)
local typeCell = gurt.create('div', { text = record.type, style = 'font-bold' })
local nameCell = gurt.create('div', { text = record.name or '@' })
local valueCell = gurt.create('div', { text = record.value, style = 'font-mono text-sm break-all' })
local ttlCell = gurt.create('div', { text = record.ttl or '3600' })
local ttlCell = gurt.create('div', { text = record.ttl or 'none' })
local actionsCell = gurt.create('div')
local deleteBtn = gurt.create('button', {
@@ -227,16 +227,10 @@ end
local function addRecord(type, name, value, ttl)
hideError('record-error')
print('Adding DNS record: ' .. type .. ' ' .. name .. ' ' .. value)
print('Network request details:')
print(' URL: gurt://localhost:8877/domain/' .. domainName .. '/records')
print(' Method: POST')
print(' Auth token: ' .. (authToken and 'present' or 'missing'))
print(' Domain name: ' .. (domainName or 'nil'))
local response = fetch('gurt://localhost:8877/domain/' .. domainName .. '/records', {
method = 'POST',
headers = {
headers = {
['Content-Type'] = 'application/json',
Authorization = 'Bearer ' .. authToken
},
@@ -248,38 +242,24 @@ local function addRecord(type, name, value, ttl)
})
})
print('Response received: ' .. tostring(response))
if response then
print('Response status: ' .. tostring(response.status))
print('Response ok: ' .. tostring(response:ok()))
if response:ok() then
print('DNS record added successfully')
-- Clear form
gurt.select('#record-name').value = ''
gurt.select('#record-value').value = ''
gurt.select('#record-ttl').value = '3600'
gurt.select('#record-ttl').value = ''
-- Add the new record to existing records array
local newRecord = response:json()
if newRecord and newRecord.id then
-- Server returned the created record, add it to our local array
table.insert(records, newRecord)
-- Check if we had no records before (showing empty message)
local wasEmpty = (#records == 1) -- If this is the first record
local wasEmpty = (#records == 1)
if wasEmpty then
-- Full re-render to replace empty message with proper table
renderRecords(false)
else
-- Just append the new record to existing table
renderRecords(true)
end
else
-- Server didn't return record details, reload to get the actual data
loadRecords()
end
else
@@ -288,7 +268,6 @@ local function addRecord(type, name, value, ttl)
print('Failed to add DNS record: ' .. error)
end
else
print('No response received from server')
showError('record-error', 'No response from server - connection failed')
print('Failed to add DNS record: No response')
end
@@ -310,7 +289,7 @@ local function updateHelpText()
local recordType = gurt.select('#record-type').value
-- Hide all help texts
local helpTypes = {'A', 'AAAA', 'CNAME', 'TXT', 'NS'}
local helpTypes = {'A', 'AAAA', 'CNAME', 'TXT'}
for _, helpType in ipairs(helpTypes) do
local helpElement = gurt.select('#help-' .. helpType)
if helpElement then
@@ -330,7 +309,7 @@ local function updateHelpText()
valueInput.placeholder = '192.168.1.1'
elseif recordType == 'AAAA' then
valueInput.placeholder = '2001:db8::1'
elseif recordType == 'CNAME' or recordType == 'NS' then
elseif recordType == 'CNAME' then
valueInput.placeholder = 'example.com'
elseif recordType == 'TXT' then
valueInput.placeholder = 'Any text content'
@@ -346,7 +325,7 @@ gurt.select('#add-record-btn'):on('click', function()
local recordType = gurt.select('#record-type').value
local recordName = gurt.select('#record-name').value
local recordValue = gurt.select('#record-value').value
local recordTTL = tonumber(gurt.select('#record-ttl').value) or 3600
local recordTTL = tonumber(gurt.select('#record-ttl').value) or ''
if not recordValue or recordValue == '' then
showError('record-error', 'Record value is required')

View File

@@ -47,7 +47,7 @@
<input id="password" type="password" placeholder="Password" required="true" />
<button type="submit" id="submit">Log In</button>
</form>
<p style="text-center mt-4 text-[#999999] text-base">Don't have an account? <a href="#">Register here</a></p>
<p style="text-center mt-4 text-[#999999] text-base">Don't have an account? <a href="/signup.html">Register here</a></p>
<p id="log-output" style="min-h-24"></p>
</div>

68
dns/frontend/signup.html Normal file
View File

@@ -0,0 +1,68 @@
<head></head>
<title>Sign Up</title>
<icon src="https://cdn-icons-png.flaticon.com/512/295/295128.png">
<meta name="theme-color" content="#1b1b1b">
<meta name="description" content="Create a new account">
<font name="roboto" src="https://fonts.gstatic.com/s/roboto/v48/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2" />
<style>
body {
bg-[#171616] font-sans text-white
}
.signup-card {
bg-[#262626] p-8 rounded-lg shadow-lg max-w-md mx-auto my-auto h-full
}
h1 {
text-3xl font-bold text-center mb-6
}
input {
w-full p-3 border border-gray-600 rounded-md bg-[#374151] text-white mb-4 placeholder:text-[#999999] outline-none active:border-red-500
}
button {
bg-[#dc2626] text-white font-medium p-3 rounded-lg w-full cursor-pointer transition-colors hover:bg-[#b91c1c] active:bg-[#991b1b]
}
a {
text-[#ef4444] hover:text-[#dc2626] cursor-pointer
}
#log-output {
text-[#fca5a5] p-4 rounded-md mt-4 font-mono max-h-40
}
.info-box {
bg-[#1f2937] p-4 rounded-md mb-4 border border-[#374151]
}
.info-text {
text-[#d1d5db] text-sm
}
</style>
<script src="signup.lua" />
</head>
<body>
<div style="signup-card">
<h1>Sign Up</h1>
<div style="info-box">
<p style="info-text">New users get 3 free domain registrations to get started!</p>
</div>
<form id="signup-form" style="mx-auto">
<input id="username" type="text" placeholder="Username" required="true" />
<input id="password" type="password" placeholder="Password" required="true" />
<input id="confirm-password" type="password" placeholder="Confirm Password" required="true" />
<button type="submit" id="submit">Create Account</button>
</form>
<p style="text-center mt-4 text-[#999999] text-base">Already have an account? <a href="index.html">Login here</a></p>
<p id="log-output" style="min-h-24"></p>
</div>
</body>

96
dns/frontend/signup.lua Normal file
View File

@@ -0,0 +1,96 @@
if gurt.crumbs.get("auth_token") then
gurt.location.goto("/dashboard.html")
end
local submitBtn = gurt.select('#submit')
local username_input = gurt.select('#username')
local password_input = gurt.select('#password')
local confirm_password_input = gurt.select('#confirm-password')
local log_output = gurt.select('#log-output')
function addLog(message)
gurt.log(message)
log_output.text = log_output.text .. message .. '\n'
end
function clearLog()
log_output.text = ''
end
function validateForm(username, password, confirmPassword)
if not username or username == '' then
addLog('Error: Username is required')
return false
end
if not password or password == '' then
addLog('Error: Password is required')
return false
end
if password ~= confirmPassword then
addLog('Error: Passwords do not match')
return false
end
if string.len(password) < 6 then
addLog('Error: Password must be at least 6 characters long')
return false
end
return true
end
submitBtn:on('submit', function(event)
local username = event.data.username
local password = event.data.password
local confirmPassword = event.data['confirm-password']
clearLog()
if not validateForm(username, password, confirmPassword) then
return
end
local request_body = JSON.stringify({
username = username,
password = password
})
local url = 'gurt://127.0.0.1:8877/auth/register'
local headers = {
['Content-Type'] = 'application/json'
}
addLog('Creating account for username: ' .. username)
local response = fetch(url, {
method = 'POST',
headers = headers,
body = request_body
})
addLog('Response Status: ' .. response.status .. ' ' .. response.statusText)
if response:ok() then
addLog('Account created successfully!')
local jsonData = response:json()
if jsonData then
addLog('Welcome, ' .. jsonData.user.username .. '!')
addLog('You have ' .. jsonData.user.registrations_remaining .. ' domain registrations available')
gurt.crumbs.set({
name = "auth_token",
value = jsonData.token,
lifespan = 604800
})
addLog('Redirecting to dashboard...')
gurt.location.goto("/dashboard.html")
end
else
addLog('Registration failed with status: ' .. response.status)
local error_data = response:text()
addLog('Error: ' .. error_data)
end
end)

View File

@@ -0,0 +1,33 @@
-- Add certificate challenges table for CA functionality
CREATE TABLE IF NOT EXISTS certificate_challenges (
id SERIAL PRIMARY KEY,
token VARCHAR(255) UNIQUE NOT NULL,
domain VARCHAR(255) NOT NULL,
challenge_type VARCHAR(20) NOT NULL CHECK (challenge_type IN ('dns')),
verification_data VARCHAR(500) NOT NULL,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'valid', 'invalid', 'expired')),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_certificate_challenges_token ON certificate_challenges(token);
CREATE INDEX IF NOT EXISTS idx_certificate_challenges_domain ON certificate_challenges(domain);
CREATE INDEX IF NOT EXISTS idx_certificate_challenges_expires_at ON certificate_challenges(expires_at);
-- Add table to store issued certificates
CREATE TABLE IF NOT EXISTS issued_certificates (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
certificate_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
issued_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
serial_number VARCHAR(255) UNIQUE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_issued_certificates_domain ON issued_certificates(domain);
CREATE INDEX IF NOT EXISTS idx_issued_certificates_user_id ON issued_certificates(user_id);
CREATE INDEX IF NOT EXISTS idx_issued_certificates_serial ON issued_certificates(serial_number);
CREATE INDEX IF NOT EXISTS idx_issued_certificates_expires_at ON issued_certificates(expires_at);

View File

@@ -0,0 +1,7 @@
-- Remove invalid record types before applying constraint
DELETE FROM dns_records WHERE record_type NOT IN ('A', 'AAAA', 'CNAME', 'TXT');
-- Now apply the constraint
ALTER TABLE dns_records DROP CONSTRAINT IF EXISTS dns_records_record_type_check;
ALTER TABLE dns_records ADD CONSTRAINT dns_records_record_type_check
CHECK (record_type IN ('A', 'AAAA', 'CNAME', 'TXT'));

View File

@@ -0,0 +1,8 @@
-- Add table to store CA certificate and key
CREATE TABLE IF NOT EXISTS ca_certificates (
id SERIAL PRIMARY KEY,
ca_cert_pem TEXT NOT NULL,
ca_key_pem TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);

View File

@@ -0,0 +1,2 @@
-- Add CSR field to certificate challenges
ALTER TABLE certificate_challenges ADD COLUMN IF NOT EXISTS csr_pem TEXT;

114
dns/src/crypto.rs Normal file
View File

@@ -0,0 +1,114 @@
use anyhow::Result;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::x509::X509Req;
use openssl::x509::X509Name;
use openssl::hash::MessageDigest;
pub fn generate_ca_cert() -> Result<(String, String)> {
let rsa = Rsa::generate(4096)?;
let ca_key = PKey::from_rsa(rsa)?;
let mut name_builder = X509Name::builder()?;
name_builder.append_entry_by_text("C", "US")?;
name_builder.append_entry_by_text("O", "Gurted Network")?;
name_builder.append_entry_by_text("CN", "Gurted Root CA")?;
let ca_name = name_builder.build();
let mut cert_builder = openssl::x509::X509::builder()?;
cert_builder.set_version(2)?;
cert_builder.set_subject_name(&ca_name)?;
cert_builder.set_issuer_name(&ca_name)?;
cert_builder.set_pubkey(&ca_key)?;
// validity period (10 years)
let not_before = openssl::asn1::Asn1Time::days_from_now(0)?;
let not_after = openssl::asn1::Asn1Time::days_from_now(3650)?;
cert_builder.set_not_before(&not_before)?;
cert_builder.set_not_after(&not_after)?;
let serial = openssl::bn::BigNum::from_u32(1)?.to_asn1_integer()?;
cert_builder.set_serial_number(&serial)?;
let context = cert_builder.x509v3_context(None, None);
let basic_constraints = openssl::x509::extension::BasicConstraints::new()
.critical()
.ca()
.build()?;
cert_builder.append_extension(basic_constraints)?;
let key_usage = openssl::x509::extension::KeyUsage::new()
.critical()
.key_cert_sign()
.crl_sign()
.build()?;
cert_builder.append_extension(key_usage)?;
cert_builder.sign(&ca_key, MessageDigest::sha256())?;
let ca_cert = cert_builder.build();
let ca_key_pem = ca_key.private_key_to_pem_pkcs8()?;
let ca_cert_pem = ca_cert.to_pem()?;
Ok((
String::from_utf8(ca_key_pem)?,
String::from_utf8(ca_cert_pem)?
))
}
pub fn sign_csr_with_ca(
csr_pem: &str,
ca_cert_pem: &str,
ca_key_pem: &str,
domain: &str
) -> Result<String> {
let ca_cert = openssl::x509::X509::from_pem(ca_cert_pem.as_bytes())?;
let ca_key = PKey::private_key_from_pem(ca_key_pem.as_bytes())?;
let csr = X509Req::from_pem(csr_pem.as_bytes())?;
let mut cert_builder = openssl::x509::X509::builder()?;
cert_builder.set_version(2)?;
cert_builder.set_subject_name(csr.subject_name())?;
cert_builder.set_issuer_name(ca_cert.subject_name())?;
cert_builder.set_pubkey(csr.public_key()?.as_ref())?;
// validity period (90 days)
let not_before = openssl::asn1::Asn1Time::days_from_now(0)?;
let not_after = openssl::asn1::Asn1Time::days_from_now(90)?;
cert_builder.set_not_before(&not_before)?;
cert_builder.set_not_after(&not_after)?;
let mut serial = openssl::bn::BigNum::new()?;
serial.rand(128, openssl::bn::MsbOption::MAYBE_ZERO, false)?;
let asn1_serial = serial.to_asn1_integer()?;
cert_builder.set_serial_number(&asn1_serial)?;
let context = cert_builder.x509v3_context(Some(&ca_cert), None);
let subject_alt_name = openssl::x509::extension::SubjectAlternativeName::new()
.dns(domain)
.dns("localhost")
.ip("127.0.0.1")
.build(&context)?;
cert_builder.append_extension(subject_alt_name)?;
let key_usage = openssl::x509::extension::KeyUsage::new()
.critical()
.digital_signature()
.key_encipherment()
.build()?;
cert_builder.append_extension(key_usage)?;
let ext_key_usage = openssl::x509::extension::ExtendedKeyUsage::new()
.server_auth()
.client_auth()
.build()?;
cert_builder.append_extension(ext_key_usage)?;
cert_builder.sign(&ca_key, MessageDigest::sha256())?;
let cert = cert_builder.build();
let cert_pem = cert.to_pem()?;
Ok(String::from_utf8(cert_pem)?)
}

View File

@@ -2,6 +2,7 @@ mod auth_routes;
mod helpers;
mod models;
mod routes;
mod ca;
use crate::{auth::jwt_middleware_gurt, config::Config, discord_bot};
use colored::Colorize;
@@ -97,6 +98,10 @@ enum HandlerType {
CreateDomainRecord,
ResolveDomain,
ResolveFullDomain,
VerifyDomainOwnership,
RequestCertificate,
GetCertificate,
GetCaCertificate,
}
impl GurtHandler for AppHandler {
@@ -167,6 +172,10 @@ impl GurtHandler for AppHandler {
},
HandlerType::ResolveDomain => routes::resolve_domain(&ctx, app_state).await,
HandlerType::ResolveFullDomain => routes::resolve_full_domain(&ctx, app_state).await,
HandlerType::VerifyDomainOwnership => routes::verify_domain_ownership(&ctx, app_state).await,
HandlerType::RequestCertificate => routes::request_certificate(&ctx, app_state).await,
HandlerType::GetCertificate => routes::get_certificate(&ctx, app_state).await,
HandlerType::GetCaCertificate => routes::get_ca_certificate(&ctx, app_state).await,
};
let duration = start_time.elapsed();
@@ -237,7 +246,11 @@ pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
.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 })
.route(Route::post("/resolve"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveDomain })
.route(Route::post("/resolve-full"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveFullDomain });
.route(Route::post("/resolve-full"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveFullDomain })
.route(Route::get("/verify-ownership/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::VerifyDomainOwnership })
.route(Route::post("/ca/request-certificate"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RequestCertificate })
.route(Route::get("/ca/certificate/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetCertificate })
.route(Route::get("/ca/root"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetCaCertificate });
log::info!("GURT server listening on {}", config.get_address());
server.listen(&config.get_address()).await.map_err(|e| {

45
dns/src/gurt_server/ca.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::crypto;
use anyhow::Result;
use sqlx::PgPool;
pub struct CaCertificate {
pub ca_cert_pem: String,
pub ca_key_pem: String,
}
pub async fn get_or_create_ca(db: &PgPool) -> Result<CaCertificate> {
if let Some(ca_cert) = get_active_ca(db).await? {
return Ok(ca_cert);
}
log::info!("Generating new CA certificate...");
let (ca_key_pem, ca_cert_pem) = crypto::generate_ca_cert()?;
sqlx::query(
"INSERT INTO ca_certificates (ca_cert_pem, ca_key_pem, is_active) VALUES ($1, $2, TRUE)"
)
.bind(&ca_cert_pem)
.bind(&ca_key_pem)
.execute(db)
.await?;
log::info!("CA certificate generated and stored");
Ok(CaCertificate {
ca_cert_pem,
ca_key_pem,
})
}
async fn get_active_ca(db: &PgPool) -> Result<Option<CaCertificate>> {
let result: Option<(String, String)> = sqlx::query_as(
"SELECT ca_cert_pem, ca_key_pem FROM ca_certificates WHERE is_active = TRUE ORDER BY created_at DESC LIMIT 1"
)
.fetch_optional(db)
.await?;
Ok(result.map(|(ca_cert_pem, ca_key_pem)| CaCertificate {
ca_cert_pem,
ca_key_pem,
}))
}

View File

@@ -82,7 +82,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>,
}

View File

@@ -390,7 +390,7 @@ pub(crate) async fn get_domain_records(ctx: &ServerContext, app_state: AppState,
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -445,13 +445,13 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Record type is required"));
}
let valid_types = ["A", "AAAA", "CNAME", "TXT", "NS"];
let valid_types = ["A", "AAAA", "CNAME", "TXT"];
if !valid_types.contains(&record_data.record_type.as_str()) {
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, TXT, and NS records are supported."));
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, and TXT records are supported."));
}
let record_name = record_data.name.unwrap_or_else(|| "@".to_string());
let ttl = record_data.ttl.unwrap_or(3600);
let ttl = record_data.ttl.filter(|t| *t > 0);
match record_data.record_type.as_str() {
"A" => {
@@ -464,9 +464,9 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Invalid IPv6 address for AAAA record"));
}
},
"CNAME" | "NS" => {
"CNAME" => {
if record_data.value.is_empty() || !record_data.value.contains('.') {
return Ok(GurtResponse::bad_request().with_string_body("CNAME and NS records must contain a valid domain name"));
return Ok(GurtResponse::bad_request().with_string_body("CNAME records must contain a valid domain name"));
}
},
"TXT" => {
@@ -498,7 +498,7 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
record_type: record_data.record_type,
name: record_name,
value: record_data.value,
ttl,
ttl: Some(ttl.unwrap_or(3600)),
priority: record_data.priority,
};
@@ -637,7 +637,7 @@ async fn try_exact_match(query_name: &str, tld: &str, app_state: &AppState) -> R
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -718,7 +718,7 @@ async fn try_delegation_match(query_name: &str, tld: &str, app_state: &AppState)
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -761,6 +761,221 @@ pub(crate) async fn resolve_full_domain(ctx: &ServerContext, app_state: AppState
}
}
// Certificate Authority endpoints
pub(crate) async fn verify_domain_ownership(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 3 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format"));
}
let domain = path_parts[2];
let domain_parts: Vec<&str> = domain.split('.').collect();
if domain_parts.len() < 2 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let name = domain_parts[0];
let tld = domain_parts[1];
let domain_record: 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"))?;
let exists = domain_record.is_some();
Ok(GurtResponse::ok().with_json_body(&serde_json::json!({
"domain": domain,
"exists": exists
}))?)
}
pub(crate) async fn request_certificate(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
#[derive(serde::Deserialize)]
struct CertRequest {
domain: String,
csr: String,
}
let cert_request: CertRequest = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let domain_parts: Vec<&str> = cert_request.domain.split('.').collect();
if domain_parts.len() < 2 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let name = domain_parts[0];
let tld = domain_parts[1];
let domain_record: 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"))?;
if domain_record.is_none() {
return Ok(GurtResponse::bad_request().with_string_body("Domain does not exist or is not approved"));
}
let token = uuid::Uuid::new_v4().to_string();
let verification_data = generate_challenge_data(&cert_request.domain, &token)?;
sqlx::query(
"INSERT INTO certificate_challenges (token, domain, challenge_type, verification_data, csr_pem, expires_at) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(&token)
.bind(&cert_request.domain)
.bind("dns") // Only DNS challenges
.bind(&verification_data)
.bind(&cert_request.csr)
.bind(chrono::Utc::now() + chrono::Duration::hours(1))
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to store challenge"))?;
let challenge = serde_json::json!({
"token": token,
"challenge_type": "dns",
"domain": cert_request.domain,
"verification_data": verification_data
});
Ok(GurtResponse::ok().with_json_body(&challenge)?)
}
pub(crate) async fn get_certificate(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"));
}
let token = path_parts[3];
let challenge: Option<(String, String, String, Option<String>, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
"SELECT domain, challenge_type, verification_data, csr_pem, expires_at FROM certificate_challenges WHERE token = $1"
)
.bind(token)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let (domain, _challenge_type, verification_data, csr_pem, expires_at) = match challenge {
Some(c) => c,
None => return Ok(GurtResponse::not_found().with_string_body("Challenge not found"))
};
let csr_pem = match csr_pem {
Some(csr) => csr,
None => return Ok(GurtResponse::bad_request().with_string_body("CSR not found for this challenge"))
};
if chrono::Utc::now() > expires_at {
return Ok(GurtResponse::bad_request().with_string_body("Challenge expired"));
}
let challenge_domain = format!("_gurtca-challenge.{}", domain);
let domain_parts: Vec<&str> = challenge_domain.split('.').collect();
if domain_parts.len() < 3 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let record_name = "_gurtca-challenge";
let base_domain_name = domain_parts[domain_parts.len() - 2];
let tld = domain_parts[domain_parts.len() - 1];
let domain_record: 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(base_domain_name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let domain_record = match domain_record {
Some(d) => d,
None => return Ok(GurtResponse::bad_request().with_string_body("Domain not found or not approved"))
};
let txt_records: Vec<DnsRecord> = sqlx::query_as::<_, DnsRecord>(
"SELECT id, domain_id, record_type, name, value, ttl, priority, created_at FROM dns_records WHERE domain_id = $1 AND record_type = 'TXT' AND name = $2 AND value = $3"
)
.bind(domain_record.id.unwrap())
.bind(record_name)
.bind(&verification_data)
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if txt_records.is_empty() {
return Ok(GurtResponse::new(gurt::GurtStatusCode::Accepted).with_string_body("Challenge not completed yet"));
}
let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
.map_err(|e| {
log::error!("Failed to get CA certificate: {}", e);
GurtError::invalid_message("CA certificate error")
})?;
let cert_pem = crate::crypto::sign_csr_with_ca(
&csr_pem,
&ca_cert.ca_cert_pem,
&ca_cert.ca_key_pem,
&domain
).map_err(|e| {
log::error!("Failed to sign certificate: {}", e);
GurtError::invalid_message("Certificate signing failed")
})?;
let certificate = serde_json::json!({
"cert_pem": cert_pem,
"chain_pem": ca_cert.ca_cert_pem,
"expires_at": (chrono::Utc::now() + chrono::Duration::days(90)).to_rfc3339()
});
// Delete the challenge as it's completed
sqlx::query("DELETE FROM certificate_challenges WHERE token = $1")
.bind(token)
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to cleanup challenge"))?;
Ok(GurtResponse::ok().with_json_body(&certificate)?)
}
pub(crate) async fn get_ca_certificate(_ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
.map_err(|e| {
log::error!("Failed to get CA certificate: {}", e);
GurtError::invalid_message("CA certificate error")
})?;
Ok(GurtResponse::ok()
.with_header("Content-Type", "application/x-pem-file")
.with_header("Content-Disposition", "attachment; filename=\"gurted-ca.crt\"")
.with_string_body(ca_cert.ca_cert_pem))
}
fn generate_challenge_data(domain: &str, token: &str) -> Result<String> {
use sha2::{Sha256, Digest};
let data = format!("{}:{}", domain, token);
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
let hash = hasher.finalize();
Ok(base64::encode(hash))
}
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,

View File

@@ -2,6 +2,7 @@ mod config;
mod gurt_server;
mod auth;
mod discord_bot;
mod crypto;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{LogLevel, Verbosity};