DNS server (add NS record)

This commit is contained in:
Face
2025-08-21 12:27:44 +03:00
parent 48820d48b5
commit 0a38af1b66
27 changed files with 2313 additions and 365 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*target*
*.pem
*gurty.toml

View File

@@ -116,6 +116,7 @@
<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">
@@ -125,6 +126,13 @@
<div style="form-group">
<p>Value:</p>
<input id="record-value" type="text" style="form-input" placeholder="192.168.1.1, example.com" />
<div id="record-help" style="text-[#6b7280] text-sm mt-1">
<span id="help-A">Enter an IPv4 address (e.g., 192.168.1.1)</span>
<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">
<p>TTL:</p>

View File

@@ -92,10 +92,16 @@ renderRecords = function(appendOnly)
if #records == 0 then
local emptyMessage = gurt.create('div', {
text = 'No DNS records found. Add your first record below!',
style = 'text-center text-[#6b7280] py-8'
style = 'text-center text-[#6b7280] py-8',
id = '404'
})
recordsList:append(emptyMessage)
return
else
local err = gurt.select('#404')
if err then
gurt.select('#404'):remove()
end
end
-- Create header only if not appending or if list is empty
@@ -222,6 +228,11 @@ 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',
@@ -237,6 +248,12 @@ 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')
@@ -250,8 +267,17 @@ local function addRecord(type, name, value, ttl)
if newRecord and newRecord.id then
-- Server returned the created record, add it to our local array
table.insert(records, newRecord)
-- Render only the new record
-- Check if we had no records before (showing empty message)
local wasEmpty = (#records == 1) -- If this is the first record
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()
@@ -261,6 +287,12 @@ local function addRecord(type, name, value, ttl)
showError('record-error', 'Failed to add record: ' .. error)
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
end
local function logout()
@@ -273,9 +305,42 @@ local function goBack()
--gurt.location.goto("/dashboard.html")
end
-- Function to update help text based on record type
local function updateHelpText()
local recordType = gurt.select('#record-type').value
-- Hide all help texts
local helpTypes = {'A', 'AAAA', 'CNAME', 'TXT', 'NS'}
for _, helpType in ipairs(helpTypes) do
local helpElement = gurt.select('#help-' .. helpType)
if helpElement then
helpElement.classList:add('hidden')
end
end
-- Show the relevant help text
local currentHelp = gurt.select('#help-' .. recordType)
if currentHelp then
currentHelp.classList:remove('hidden')
end
-- Update placeholder text based on record type
local valueInput = gurt.select('#record-value')
if recordType == 'A' then
valueInput.placeholder = '192.168.1.1'
elseif recordType == 'AAAA' then
valueInput.placeholder = '2001:db8::1'
elseif recordType == 'CNAME' or recordType == 'NS' then
valueInput.placeholder = 'example.com'
elseif recordType == 'TXT' then
valueInput.placeholder = 'Any text content'
end
end
-- Event handlers
gurt.select('#logout-btn'):on('click', logout)
gurt.select('#back-btn'):on('click', goBack)
gurt.select('#record-type'):on('change', updateHelpText)
gurt.select('#add-record-btn'):on('click', function()
local recordType = gurt.select('#record-type').value
@@ -297,4 +362,5 @@ end)
-- Initialize
print('Domain management page initialized')
updateHelpText() -- Set initial help text
checkAuth()

View File

@@ -7,35 +7,33 @@
<font name="roboto" src="https://fonts.gstatic.com/s/roboto/v48/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2" />
<style>
body { bg-[#2a2a2a] text-[#ffffff] font-roboto flex items-center justify-center p-4 }
.login-card { p-8 }
h1 { text-3xl font-bold text-center mb-6 text-[#ffffff] }
body {
bg-[#171616] font-sans text-white
}
.login-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 {
bg-[#3b3b3b]
border-none
rounded-md
p-3
w-full
text-[#ffffff]
placeholder:text-[#999999]
outline-none
focus:ring-2
focus:ring-[#5b5b5b]
mb-4
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-[#4ade80]
text-[#1b1b1b]
font-bold
p-3
rounded-md
w-full
hover:bg-[#22c55e]
active:bg-[#15803d]
cursor-pointer
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
}
a { text-[#4ade80] hover:text-[#22c55e] cursor-pointer }
#log-output { text-white p-4 rounded-md mt-4 font-mono max-h-40 }
</style>
<script src="script.lua" />

View File

@@ -105,10 +105,7 @@
<p id="tld-loading">Loading TLDs...</p>
</div>
</div>
<div style="form-group">
<p>IP Address:</p>
<input id="domain-ip" type="text" style="form-input" placeholder="192.168.1.100" />
</div>
<p style="text-[#6b7280] text-sm mb-4">Note: After registration is approved, you can add DNS records (A, AAAA, CNAME, TXT) to configure where your domain points.</p>
<div id="domain-error" style="error-text hidden mb-2"></div>
<button id="submit-domain-btn" style="success-btn">Submit for Approval</button>
</div>

View File

@@ -122,7 +122,7 @@ local function goToDashboard()
gurt.location.goto("/dashboard.html")
end
local function submitDomain(name, tld, ip)
local function submitDomain(name, tld)
hideError('domain-error')
print('Submitting domain: ' .. name .. '.' .. tld)
@@ -132,7 +132,7 @@ local function submitDomain(name, tld, ip)
['Content-Type'] = 'application/json',
Authorization = 'Bearer ' .. authToken
},
body = JSON.stringify({ name = name, tld = tld, ip = ip })
body = JSON.stringify({ name = name, tld = tld })
})
if response:ok() then
@@ -144,7 +144,6 @@ local function submitDomain(name, tld, ip)
-- Clear form
gurt.select('#domain-name').text = ''
gurt.select('#domain-ip').text = ''
-- Redirect to dashboard
gurt.location.goto('/dashboard.html')
@@ -212,12 +211,10 @@ gurt.select('#dashboard-btn'):on('click', goToDashboard)
gurt.select('#submit-domain-btn'):on('click', function()
local name = gurt.select('#domain-name').value
local ip = gurt.select('#domain-ip').value
local selectedTLD = gurt.select('.tld-selected')
print('Submit domain button clicked')
print('Input name:', name)
print('Input IP:', ip)
print('Selected TLD element:', selectedTLD)
if not name or name == '' then
@@ -226,12 +223,6 @@ gurt.select('#submit-domain-btn'):on('click', function()
return
end
if not ip or ip == '' then
print('Validation failed: IP address is required')
showError('domain-error', 'IP address is required')
return
end
if not selectedTLD then
print('Validation failed: No TLD selected')
showError('domain-error', 'Please select a TLD')
@@ -239,8 +230,8 @@ gurt.select('#submit-domain-btn'):on('click', function()
end
local tld = selectedTLD:getAttribute('data-tld')
print('Submitting domain with name:', name, 'tld:', tld, 'ip:', ip)
submitDomain(name, tld, ip)
print('Submitting domain with name:', name, 'tld:', tld)
submitDomain(name, tld)
end)
gurt.select('#create-invite-btn'):on('click', createInvite)

View File

@@ -0,0 +1,7 @@
-- Make IP column optional for domains
ALTER TABLE domains ALTER COLUMN ip DROP NOT NULL;
-- Update DNS records constraint to only allow A, AAAA, CNAME, TXT
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,10 @@
-- Re-add NS record support and extend record types
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', 'NS', 'MX'));
-- Add index for efficient NS record lookups during delegation
CREATE INDEX IF NOT EXISTS idx_dns_records_ns_lookup ON dns_records(record_type, name) WHERE record_type = 'NS';
-- Add index for subdomain resolution optimization
CREATE INDEX IF NOT EXISTS idx_dns_records_subdomain_lookup ON dns_records(domain_id, name, record_type);

View File

@@ -0,0 +1,8 @@
-- Fix record types to remove MX and ensure NS is supported
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', 'NS'));
-- Add indexes for efficient DNS lookups if they don't exist
CREATE INDEX IF NOT EXISTS idx_dns_records_ns_lookup ON dns_records(record_type, name) WHERE record_type = 'NS';
CREATE INDEX IF NOT EXISTS idx_dns_records_subdomain_lookup ON dns_records(domain_id, name, record_type);

View File

@@ -2,16 +2,11 @@ use serenity::async_trait;
use serenity::all::*;
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,
}
@@ -40,6 +35,16 @@ impl EventHandler for BotHandler {
}
};
// Get domain info for the updated embed
let domain: Option<(String, String, String)> = sqlx::query_as(
"SELECT d.name, d.tld, u.username FROM domains d JOIN users u ON d.user_id = u.id WHERE d.id = $1"
)
.bind(domain_id)
.fetch_optional(&self.pool)
.await
.unwrap_or(None);
if let Some((name, tld, username)) = domain {
// Update domain status to approved
match sqlx::query("UPDATE domains SET status = 'approved' WHERE id = $1")
.bind(domain_id)
@@ -47,6 +52,7 @@ impl EventHandler for BotHandler {
.await
{
Ok(_) => {
// First, send ephemeral confirmation
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("✅ Domain approved!")
@@ -55,6 +61,24 @@ impl EventHandler for BotHandler {
if let Err(e) = component.create_response(&ctx.http, response).await {
log::error!("Error responding to interaction: {}", e);
return;
}
// Then edit the original message with green color and no buttons
let updated_embed = CreateEmbed::new()
.title("✅ Domain Registration - APPROVED")
.field("Domain", format!("{}.{}", name, tld), true)
.field("User", username, true)
.field("Status", "Approved", true)
.color(0x00ff00); // Green color
let edit_message = EditMessage::new()
.embed(updated_embed)
.components(vec![]); // Remove buttons
let mut message = component.message.clone();
if let Err(e) = message.edit(&ctx.http, edit_message).await {
log::error!("Error updating original message: {}", e);
}
}
Err(e) => {
@@ -67,6 +91,7 @@ impl EventHandler for BotHandler {
let _ = component.create_response(&ctx.http, response).await;
}
}
}
} else if custom_id.starts_with("deny_") {
let domain_id = custom_id.strip_prefix("deny_").unwrap();
@@ -116,6 +141,16 @@ impl EventHandler for BotHandler {
})
.unwrap_or("No reason provided");
// Get domain info for the updated embed
let domain: Option<(String, String, String)> = sqlx::query_as(
"SELECT d.name, d.tld, u.username FROM domains d JOIN users u ON d.user_id = u.id WHERE d.id = $1"
)
.bind(domain_id)
.fetch_optional(&self.pool)
.await
.unwrap_or(None);
if let Some((name, tld, username)) = domain {
// Update domain status to denied with reason
match sqlx::query("UPDATE domains SET status = 'denied', denial_reason = $1 WHERE id = $2")
.bind(reason)
@@ -124,6 +159,7 @@ impl EventHandler for BotHandler {
.await
{
Ok(_) => {
// First, send ephemeral confirmation
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("❌ Domain denied!")
@@ -132,6 +168,28 @@ impl EventHandler for BotHandler {
if let Err(e) = modal_submit.create_response(&ctx.http, response).await {
log::error!("Error responding to modal: {}", e);
return;
}
// Then edit the original message with red color and no buttons
let updated_embed = CreateEmbed::new()
.title("❌ Domain Registration - DENIED")
.field("Domain", format!("{}.{}", name, tld), true)
.field("User", username, true)
.field("Status", "Denied", true)
.field("Reason", reason, false)
.color(0xff0000); // Red color
let edit_message = EditMessage::new()
.embed(updated_embed)
.components(vec![]); // Remove buttons
if let Some(mut message) = modal_submit.message.clone() {
if let Err(e) = message.edit(&ctx.http, edit_message).await {
log::error!("Error updating original message: {}", e);
}
} else {
log::error!("Original message not found for editing");
}
}
Err(e) => {
@@ -146,6 +204,7 @@ impl EventHandler for BotHandler {
}
}
}
}
_ => {
// Handle other interaction types if needed
log::debug!("Unhandled interaction type: {:?}", interaction.kind());
@@ -162,12 +221,12 @@ pub async fn send_domain_approval_request(
let http = serenity::http::Http::new(bot_token);
let embed = CreateEmbed::new()
.title("New Domain Registration")
.title("Domain request")
.field("Domain", format!("{}.{}", registration.domain_name, registration.tld), true)
.field("IP", &registration.ip, true)
.field("User", &registration.username, true)
.field("User ID", registration.user_id.to_string(), true)
.color(0x00ff00);
.field("Status", "Pending Review", true)
.color(0x808080); // Gray color for pending
let approve_button = CreateButton::new(format!("approve_{}", registration.id))
.style(ButtonStyle::Success)

View File

@@ -95,6 +95,8 @@ enum HandlerType {
DeleteDomain,
GetUserDomains,
CreateDomainRecord,
ResolveDomain,
ResolveFullDomain,
}
impl GurtHandler for AppHandler {
@@ -163,6 +165,8 @@ impl GurtHandler for AppHandler {
handle_authenticated!(ctx, app_state, routes::delete_domain)
}
},
HandlerType::ResolveDomain => routes::resolve_domain(&ctx, app_state).await,
HandlerType::ResolveFullDomain => routes::resolve_full_domain(&ctx, app_state).await,
};
let duration = start_time.elapsed();
@@ -231,7 +235,9 @@ pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
.route(Route::get("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetDomain })
.route(Route::post("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::CreateDomainRecord })
.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::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 });
log::info!("GURT server listening on {}", config.get_address());
server.listen(&config.get_address()).await.map_err(|e| {

View File

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

View File

@@ -6,11 +6,12 @@ use sqlx::{FromRow, types::chrono::{DateTime, Utc}};
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(deserialize_with = "deserialize_lowercase")]
pub(crate) tld: String,
#[serde(skip_deserializing)]
pub(crate) ip: Option<String>,
#[serde(skip_deserializing)]
pub(crate) user_id: Option<i32>,
#[serde(skip_deserializing)]
@@ -70,7 +71,6 @@ pub struct DomainInviteCode {
#[derive(Debug, Serialize)]
pub(crate) struct ResponseDomain {
pub(crate) tld: String,
pub(crate) ip: String,
pub(crate) name: String,
pub(crate) records: Option<Vec<ResponseDnsRecord>>,
}
@@ -87,8 +87,16 @@ pub(crate) struct ResponseDnsRecord {
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct UpdateDomain {
pub(crate) ip: String,
pub(crate) struct DnsResolutionRequest {
pub(crate) name: String,
pub(crate) tld: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct DnsResolutionResponse {
pub(crate) name: String,
pub(crate) tld: String,
pub(crate) records: Vec<ResponseDnsRecord>,
}
#[derive(Serialize)]
@@ -108,7 +116,6 @@ pub(crate) struct DomainList {
pub(crate) struct UserDomain {
pub(crate) name: String,
pub(crate) tld: String,
pub(crate) ip: String,
pub(crate) status: String,
pub(crate) denial_reason: Option<String>,
}

View File

@@ -1,5 +1,6 @@
use super::{models::*, AppState, helpers::validate_ip};
use super::{models::*, AppState};
use crate::auth::Claims;
use crate::discord_bot::{send_domain_approval_request, DomainRegistration};
use gurt::prelude::*;
use std::{env, collections::HashMap};
@@ -23,8 +24,6 @@ pub(crate) async fn index(_app_state: AppState) -> Result<GurtResponse> {
}
pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -> Result<Domain> {
validate_ip(&domain)?;
if !app.config.tld_list().contains(&domain.tld.as_str())
|| !domain.name.chars().all(|c| c.is_alphabetic() || c == '-')
|| domain.name.len() > 24
@@ -51,17 +50,26 @@ pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -
return Err(GurtError::invalid_message("Domain already exists"));
}
sqlx::query(
"INSERT INTO domains (name, tld, ip, user_id, status) VALUES ($1, $2, $3, $4, 'pending')"
let user: (String,) = sqlx::query_as("SELECT username FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&app.db)
.await
.map_err(|_| GurtError::invalid_message("User not found"))?;
let username = user.0;
let domain_row: (i32,) = sqlx::query_as(
"INSERT INTO domains (name, tld, user_id, status) VALUES ($1, $2, $3, 'pending') RETURNING id"
)
.bind(&domain.name)
.bind(&domain.tld)
.bind(&domain.ip)
.bind(user_id)
.execute(&app.db)
.fetch_one(&app.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to create domain"))?;
let domain_id = domain_row.0;
// Decrease user's registrations remaining
sqlx::query("UPDATE users SET registrations_remaining = registrations_remaining - 1 WHERE id = $1")
.bind(user_id)
@@ -69,6 +77,29 @@ pub(crate) async fn create_logic(domain: Domain, user_id: i32, app: &AppState) -
.await
.map_err(|_| GurtError::invalid_message("Failed to update user registrations"))?;
if !app.config.discord.bot_token.is_empty() && app.config.discord.channel_id != 0 {
let domain_registration = DomainRegistration {
id: domain_id,
domain_name: domain.name.clone(),
tld: domain.tld.clone(),
user_id,
username: username.clone(),
};
let channel_id = app.config.discord.channel_id;
let bot_token = app.config.discord.bot_token.clone();
tokio::spawn(async move {
if let Err(e) = send_domain_approval_request(
channel_id,
domain_registration,
&bot_token,
).await {
log::error!("Failed to send Discord notification: {}", e);
}
});
}
Ok(domain)
}
@@ -177,7 +208,6 @@ pub(crate) async fn get_domains(ctx: &ServerContext, app_state: AppState) -> Res
ResponseDomain {
name: domain.name,
tld: domain.tld,
ip: domain.ip,
records: None,
}
}).collect();
@@ -226,56 +256,8 @@ pub(crate) async fn check_domain(ctx: &ServerContext, app_state: AppState) -> Re
Ok(GurtResponse::ok().with_json_body(&domain_list)?)
}
pub(crate) async fn update_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 4 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format. Expected /domain/{name}/{tld}"));
}
let name = path_parts[2];
let tld = path_parts[3];
let update_data: UpdateDomain = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
// Verify user owns this domain
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND user_id = $3"
)
.bind(name)
.bind(tld)
.bind(claims.user_id)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let domain = match domain {
Some(d) => d,
None => return Ok(GurtResponse::not_found().with_string_body("Domain not found or access denied"))
};
// Validate IP
validate_ip(&Domain {
id: domain.id,
name: domain.name.clone(),
tld: domain.tld.clone(),
ip: update_data.ip.clone(),
user_id: domain.user_id,
status: domain.status,
denial_reason: domain.denial_reason,
created_at: domain.created_at,
})?;
sqlx::query("UPDATE domains SET ip = $1 WHERE name = $2 AND tld = $3 AND user_id = $4")
.bind(&update_data.ip)
.bind(name)
.bind(tld)
.bind(claims.user_id)
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to update domain"))?;
Ok(GurtResponse::ok().with_string_body("Domain updated successfully"))
pub(crate) async fn update_domain(_ctx: &ServerContext, _app_state: AppState, _claims: Claims) -> Result<GurtResponse> {
return Ok(GurtResponse::bad_request().with_string_body("Domain updates are no longer supported. Use DNS records instead."));
}
pub(crate) async fn delete_domain(ctx: &ServerContext, app_state: AppState, claims: Claims) -> Result<GurtResponse> {
@@ -349,7 +331,6 @@ pub(crate) async fn get_user_domains(ctx: &ServerContext, app_state: AppState, c
UserDomain {
name: domain.name,
tld: domain.tld,
ip: domain.ip,
status: domain.status.unwrap_or_else(|| "pending".to_string()),
denial_reason: domain.denial_reason,
}
@@ -464,14 +445,38 @@ 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", "MX", "NS", "SRV"];
let valid_types = ["A", "AAAA", "CNAME", "TXT", "NS"];
if !valid_types.contains(&record_data.record_type.as_str()) {
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type"));
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, TXT, and NS records are supported."));
}
let record_name = record_data.name.unwrap_or_else(|| "@".to_string());
let ttl = record_data.ttl.unwrap_or(3600);
match record_data.record_type.as_str() {
"A" => {
if !record_data.value.parse::<std::net::Ipv4Addr>().is_ok() {
return Ok(GurtResponse::bad_request().with_string_body("Invalid IPv4 address for A record"));
}
},
"AAAA" => {
if !record_data.value.parse::<std::net::Ipv6Addr>().is_ok() {
return Ok(GurtResponse::bad_request().with_string_body("Invalid IPv6 address for AAAA record"));
}
},
"CNAME" | "NS" => {
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"));
}
},
"TXT" => {
// TXT records can contain any text
},
_ => {
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type"));
}
}
let record_id: (i32,) = sqlx::query_as(
"INSERT INTO dns_records (domain_id, record_type, name, value, ttl, priority) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id"
)
@@ -550,6 +555,212 @@ pub(crate) async fn delete_domain_record(ctx: &ServerContext, app_state: AppStat
Ok(GurtResponse::ok().with_string_body("DNS record deleted successfully"))
}
pub(crate) async fn resolve_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let resolution_request: DnsResolutionRequest = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let full_domain = format!("{}.{}", resolution_request.name, resolution_request.tld);
// Try to resolve with enhanced subdomain and delegation support
match resolve_dns_with_delegation(&full_domain, &app_state).await {
Ok(response) => Ok(GurtResponse::ok().with_json_body(&response)?),
Err(_) => Ok(GurtResponse::not_found().with_json_body(&Error {
msg: "Domain not found",
error: "Domain not found, not approved, or delegation failed".into(),
})?),
}
}
async fn resolve_dns_with_delegation(query_name: &str, app_state: &AppState) -> Result<DnsResolutionResponse> {
// Parse the query domain
let parts: Vec<&str> = query_name.split('.').collect();
if parts.len() < 2 {
return Err(GurtError::invalid_message("Invalid domain format"));
}
let tld = parts.last().unwrap();
// Try to find exact match first
if let Some(response) = try_exact_match(query_name, tld, app_state).await? {
return Ok(response);
}
// Try to find delegation by checking parent domains
if let Some(response) = try_delegation_match(query_name, tld, app_state).await? {
return Ok(response);
}
Err(GurtError::invalid_message("No matching records or delegation found"))
}
async fn try_exact_match(query_name: &str, tld: &str, app_state: &AppState) -> Result<Option<DnsResolutionResponse>> {
let parts: Vec<&str> = query_name.split('.').collect();
if parts.len() < 2 {
return Ok(None);
}
// For a query like "api.blog.example.com", try different combinations
for i in (1..parts.len()).rev() {
let domain_name = parts[parts.len() - i - 1];
let subdomain_parts = &parts[0..parts.len() - i - 1];
let subdomain = if subdomain_parts.is_empty() {
"@".to_string()
} else {
subdomain_parts.join(".")
};
// Look for the domain in our database
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(domain_name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if let Some(domain) = domain {
// Look for specific records for this subdomain
let 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 name = $2 ORDER BY created_at ASC"
)
.bind(domain.id.unwrap())
.bind(&subdomain)
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if !records.is_empty() {
let response_records: Vec<ResponseDnsRecord> = records.into_iter().map(|record| {
ResponseDnsRecord {
id: record.id.unwrap(),
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
priority: record.priority,
}
}).collect();
return Ok(Some(DnsResolutionResponse {
name: query_name.to_string(),
tld: tld.to_string(),
records: response_records,
}));
}
}
}
Ok(None)
}
async fn try_delegation_match(query_name: &str, tld: &str, app_state: &AppState) -> Result<Option<DnsResolutionResponse>> {
let parts: Vec<&str> = query_name.split('.').collect();
// Try to find NS records for parent domains
for i in (1..parts.len()).rev() {
let domain_name = parts[parts.len() - i - 1];
let subdomain_parts = &parts[0..parts.len() - i - 1];
let subdomain = if subdomain_parts.is_empty() {
"@".to_string()
} else {
subdomain_parts.join(".")
};
// Look for the domain
let domain: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(domain_name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if let Some(domain) = domain {
// Look for NS records that match this subdomain or parent
let ns_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 = 'NS' AND (name = $2 OR name = $3) ORDER BY created_at ASC"
)
.bind(domain.id.unwrap())
.bind(&subdomain)
.bind("@")
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if !ns_records.is_empty() {
// Also look for glue records (A/AAAA records for the NS hosts)
let mut all_records = ns_records;
// Get glue records for NS entries that point to subdomains of this zone
for ns_record in &all_records.clone() {
let ns_host = &ns_record.value;
if ns_host.ends_with(&format!(".{}.{}", domain_name, tld)) ||
ns_host == &format!("{}.{}", domain_name, tld) {
let glue_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 = 'A' OR record_type = 'AAAA') AND value = $2"
)
.bind(domain.id.unwrap())
.bind(ns_host)
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
all_records.extend(glue_records);
}
}
let response_records: Vec<ResponseDnsRecord> = all_records.into_iter().map(|record| {
ResponseDnsRecord {
id: record.id.unwrap(),
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
priority: record.priority,
}
}).collect();
return Ok(Some(DnsResolutionResponse {
name: query_name.to_string(),
tld: tld.to_string(),
records: response_records,
}));
}
}
}
Ok(None)
}
pub(crate) async fn resolve_full_domain(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
#[derive(serde::Deserialize)]
struct FullDomainRequest {
domain: String,
record_type: Option<String>,
}
let request: FullDomainRequest = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
// Try to resolve with enhanced subdomain and delegation support
match resolve_dns_with_delegation(&request.domain, &app_state).await {
Ok(mut response) => {
// Filter by record type if specified
if let Some(record_type) = request.record_type {
response.records.retain(|r| r.record_type == record_type);
}
Ok(GurtResponse::ok().with_json_body(&response)?)
}
Err(_) => Ok(GurtResponse::not_found().with_json_body(&Error {
msg: "Domain not found",
error: "Domain not found, not approved, or delegation failed".into(),
})?),
}
}
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,

View File

@@ -1,6 +1,5 @@
mod config;
mod gurt_server;
mod secret;
mod auth;
mod discord_bot;

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -2,12 +2,255 @@
sidebar_position: 6
---
# DNS
# DNS System
The Gurted ecosystem features a custom DNS system that enables domain resolution for the gurt:// protocol. Unlike traditional DNS, Gurted DNS is designed specifically for the decentralized web ecosystem, providing:
- domain registration
- approval workflows (to ensure a free-of-charge, spam-free experience)
- archivable domain records
- Domain registration with approval workflows
- DNS record management (A, AAAA, CNAME, TXT, NS)
- Subdomain support with delegation
- CNAME chain resolution
- Nameserver delegation with glue records
TODO: complete
DNS queries in Gurted are transmitted using the GURT protocol, which, similar to DNS over HTTPS (DoH), encrypts DNS resolution under TLS. This ensures that your DNS requests are private and secure, making it impossible for your internet provider to see which sites you visit unless they map the IP.
## Record Types
### A Records
- **Purpose**: Map domain names to IPv4 addresses
- **Format**: `example.web → 192.168.1.1`
### AAAA Records
- **Purpose**: Map domain names to IPv6 addresses
- **Format**: `example.web → 2001:db8::1`
### CNAME Records
- **Purpose**: Create aliases that point to other domain names
- **Format**: `www.example.web → example.web`
- **Chain Resolution**: Supports up to 5 levels of CNAME chaining
### TXT Records
- **Purpose**: Store arbitrary text data
- **Format**: `example.web → "v=spf1 include:_spf.example.web ~all"`
### NS Records
- **Purpose**: Delegate subdomains to other nameservers
- **Format**: `subdomain.example.web → ns1.provider.web`
- **Glue Records**: Automatically handled for in-zone nameservers
## DNS Resolution Flow
### 1. Basic Domain Resolution
Flumi follows a straightforward resolution process for domains like `example.web`:
```mermaid
graph LR
A[Browser requests example.web] --> B[Parse domain]
B --> C[Check DNS cache]
C --> D{Cache hit?}
D -->|Yes| E[Return cached IP]
D -->|No| F[Query DNS server]
F --> G[Return A record]
G --> H[Cache result]
H --> I[Connect to IP]
```
### 2. Subdomain Resolution
For queries like `api.blog.example.web`:
1. **Exact Match Check**: Look for specific records for `api.blog.example.web`
2. **Parent Domain Check**: If not found, check parent domains (`blog.example.web`, then `example.web`)
3. **Delegation Check**: Look for NS records that might delegate the subdomain
### 3. CNAME Chain Resolution
```mermaid
graph TD
A[Query www.example.web] --> B{Record type?}
B -->|A/AAAA| C[Return IP directly]
B -->|CNAME| D[Follow CNAME target]
D --> E[Query target domain]
E --> F{Target type?}
F -->|A/AAAA| G[Return final IP]
F -->|CNAME| H[Continue chain]
H --> I{Max depth reached?}
I -->|No| E
I -->|Yes| J[Error: Chain too deep]
```
### 4. NS Delegation
When a domain has NS records:
1. **Delegation Response**: Return NS records and any glue records
2. **Glue Records**: Include A/AAAA records for nameservers within the same zone
3. **Client Resolution**: Client queries the delegated nameservers directly
## API Endpoints
### Domain Management
- `POST /domain` - Register a new domain
- `GET /domain/{name}.{tld}` - Get domain details
- `DELETE /domain/{name}/{tld}` - Delete domain
### DNS Records
- `GET /domain/{name}.{tld}/records` - List all records for domain
- `POST /domain/{name}.{tld}/records` - Create new DNS record
- `DELETE /domain/{name}.{tld}/records/{id}` - Delete DNS record
### DNS Resolution
- `POST /resolve` - Legacy resolution for simple domains
- `POST /resolve-full` - Advanced resolution with subdomain support
#### Request
```json
{
"domain": "api.blog.example.web",
"record_type": "A" // optional filter
}
```
#### Response
```json
{
"name": "api.blog.example.web",
"tld": "web",
"records": [
{
"id": 123,
"type": "A",
"name": "api.blog",
"value": "192.168.1.100",
"ttl": 3600,
"priority": null
}
]
}
```
## Error Handling
### DNS Resolution Errors
- `ERR_NAME_NOT_RESOLVED` - Domain not found or not approved
- `ERR_CONNECTION_TIMED_OUT` - DNS server timeout
- `ERR_CONNECTION_REFUSED` - Cannot connect to target server
- `ERR_INVALID_URL` - Malformed domain name
### CNAME Specific Errors
- `CNAME chain too deep` - More than 5 CNAME redirections
- `CNAME chain ended without A record` - Chain leads to non-resolvable target
- `Failed to resolve CNAME target` - Intermediate domain resolution failure
### NS Delegation Errors
- `Domain is delegated to nameservers` - Cannot resolve directly, requires external resolution
- `Only IPv6 (AAAA) records found` - IPv4 required for GURT protocol
## Tutorial: Setting Up NS Record Delegation
This tutorial walks you through setting up nameserver delegation for your domain, allowing you to delegate subdomains to external DNS providers or your own nameservers.
### Step 1: Planning Your Delegation
Before setting up NS records, decide:
- **Subdomain to delegate**: e.g., `blog.example.web`
- **Target nameservers**: e.g., `ns1.blogprovider.web`, `ns2.blogprovider.web`
- **Whether you need glue records**: Required if nameservers are within your domain
### Step 2: Creating NS Records
#### Option A: Through Web Interface
1. **Access dns.web**
- Navigate to `gurt://dns.web`
- Select the domain you want to configure (e.g., `example.web`)
2. **Add NS Record**
- Select "NS" from the record type dropdown
- Enter the subdomain name: `blog` (for `blog.example.web`)
- Enter the nameserver: `ns1.blogprovider.com`
- Click "Add Record"
3. **Add Additional Nameservers**
- Repeat for each nameserver (typically 2-4 nameservers)
- Use the same subdomain name but different nameserver values
### Step 3: Setting Up Glue Records (optional)
Glue records are required when your nameserver hostnames are within the domain you're delegating.
**Example**: If you want to delegate `blog.example.web` to `ns1.blog.example.web`:
1. **Create the NS Record**
`NS`, "blog" -> `ns1.blog.example.web`
2. **Create the Glue Record**
`A`, "ns1.blog" -> `203.0.113.10`
Without the glue record, there would be a circular dependency: to find the IP of `ns1.blog.example.web`, you'd need to query the nameservers for `blog.example.web`, but those nameservers are hosted at `ns1.blog.example.web`.
### Step 4: Configuring the Target Gurted DNS
If you're delegating to another Gurted DNS instance, the target server needs to be configured to handle your subdomain.
#### Target Server Setup
1. **Register the delegated domain** on the target Gurted DNS server
- Navigate to the target DNS server (e.g., `gurt://blogprovider.web`)
- Register `blog.example.web` as a new domain
- Get it approved by the target server administrators
2. **Add records for the subdomain**
- Add A records: `blog.example.web → 192.0.2.100`
- Add subdomains: `www.blog.example.web → 192.0.2.100`
- Add API endpoints: `api.blog.example.web → 192.0.2.101`
### Step 5: Testing Your Delegation
#### Through Flumi Browser
1. **Test the delegated domain**
- Navigate to `gurt://blog.example.web` in Flumi
- Should resolve through the delegated nameservers
2. **Test subdomains**
- Try `gurt://www.blog.example.web`
- Try `gurt://api.blog.example.web`
3. **Check delegation in DNS interface**
- Go to `gurt://dns.web`
- Look up `blog.example.web`
- Should show NS records pointing to your target nameservers
### Real-World Examples
#### Example 1: Blog Delegation
```
Domain: example.web (managed on dns.web)
Delegation: blog.example.web → blogprovider.web
Setup:
1. On dns.web: Add NS record "blog" → "dns.blogprovider.web"
2. On blogprovider.web: Register and manage "blog.example.web"
3. Test: gurt://blog.example.web should resolve through blogprovider.web
```
#### Example 2: API Service Delegation
```
Domain: company.web (managed on dns.web)
Delegation: api.company.web → apiservice.web
Setup:
1. On dns.web: Add NS record "api" → "ns.apiservice.web"
2. On apiservice.web: Register "api.company.web" and add A records
3. Test: gurt://api.company.web/v1/users resolves to API server
```

View File

@@ -36,6 +36,10 @@ const config: Config = {
locales: ['en'],
},
themes: ['@docusaurus/theme-mermaid'],
markdown: {
mermaid: true,
},
presets: [
[
'classic',

1196
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"dependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@docusaurus/theme-mermaid": "^3.8.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",

View File

@@ -3,7 +3,7 @@
[ext_resource type="Script" uid="uid://bmu8q4rm1wopd" path="res://Scripts/Tags/select.gd" id="1_select"]
[ext_resource type="Theme" uid="uid://bn6rbmdy60lhr" path="res://Scenes/Styles/BrowserText.tres" id="2_xq313"]
[node name="VBoxContainer" type="VBoxContainer"]
[node name="VBoxContainer2" type="VBoxContainer"]
offset_right = 40.0
offset_bottom = 40.0
script = ExtResource("1_select")

View File

@@ -60,7 +60,18 @@ corner_radius_bottom_left = 50
bg_color = Color(0.6, 0.6, 0.6, 0)
draw_center = false
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_u50mg"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ee4r6"]
bg_color = Color(0.168627, 0.168627, 0.168627, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.247059, 0.466667, 0.807843, 1)
corner_radius_top_left = 15
corner_radius_top_right = 15
corner_radius_bottom_right = 15
corner_radius_bottom_left = 15
expand_margin_left = 40.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cbgmd"]
bg_color = Color(0.168627, 0.168627, 0.168627, 1)
@@ -71,7 +82,7 @@ corner_radius_bottom_left = 15
expand_margin_left = 40.0
[sub_resource type="Theme" id="Theme_jjvhh"]
LineEdit/styles/focus = SubResource("StyleBoxEmpty_u50mg")
LineEdit/styles/focus = SubResource("StyleBoxFlat_ee4r6")
LineEdit/styles/normal = SubResource("StyleBoxFlat_cbgmd")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_white"]
@@ -192,10 +203,10 @@ caret_blink = true
[node name="TextureRect" type="TextureRect" parent="VBoxContainer/HBoxContainer/LineEdit"]
layout_mode = 2
offset_left = -32.0
offset_right = -2.0
offset_bottom = 53.0
scale = Vector2(0.85, 0.85)
offset_left = -27.855
offset_right = 2.145
offset_bottom = 69.0
scale = Vector2(0.65, 0.65)
texture = ExtResource("3_8gbba")
stretch_mode = 5

View File

@@ -3,6 +3,9 @@ class_name GurtProtocol
const DNS_API_URL = "gurt://localhost:8877"
# DNS resolution cache: domain.tld -> IP address
static var _dns_cache: Dictionary = {}
static func is_gurt_domain(url: String) -> bool:
if url.begins_with("gurt://"):
return true
@@ -15,26 +18,62 @@ static func is_gurt_domain(url: String) -> bool:
static func parse_gurt_domain(url: String) -> Dictionary:
var domain_part = url
var path = "/"
if url.begins_with("gurt://"):
domain_part = url.substr(7)
# Extract path from domain_part (e.g., "test.dawg/script.lua" -> "test.dawg" + "/script.lua")
var path_start = domain_part.find("/")
if path_start != -1:
path = domain_part.substr(path_start)
domain_part = domain_part.substr(0, path_start)
# Check if domain is cached (resolved before)
var domain_key = domain_part
if _dns_cache.has(domain_key):
return {
"direct_address": _dns_cache[domain_key],
"display_url": domain_part + path,
"is_direct": true,
"path": path,
"full_domain": domain_part
}
if domain_part.contains(":") or domain_part.begins_with("127.0.0.1") or domain_part.begins_with("localhost") or is_ip_address(domain_part):
return {
"direct_address": domain_part,
"display_url": domain_part,
"is_direct": true
"display_url": domain_part + path,
"is_direct": true,
"path": path,
"full_domain": domain_part
}
var parts = domain_part.split(".")
if parts.size() != 2:
if parts.size() < 2:
return {}
# Support subdomains (e.g., api.blog.example.com)
if parts.size() == 2:
return {
"name": parts[0],
"tld": parts[1],
"display_url": domain_part,
"is_direct": false
"display_url": domain_part + path,
"is_direct": false,
"path": path,
"full_domain": domain_part,
"is_subdomain": false
}
else:
return {
"name": parts[parts.size() - 2], # The domain name part
"tld": parts[parts.size() - 1], # The TLD part
"display_url": domain_part + path,
"is_direct": false,
"path": path,
"full_domain": domain_part,
"is_subdomain": true,
"subdomain_parts": parts.slice(0, parts.size() - 2)
}
static func is_ip_address(address: String) -> bool:
@@ -52,39 +91,102 @@ static func is_ip_address(address: String) -> bool:
return true
static func fetch_domain_info(name: String, tld: String) -> Dictionary:
var path = "/domain/" + name + "/" + tld
var dns_address = "localhost:8877"
var request_data = JSON.stringify({"name": name, "tld": tld})
var result = await fetch_dns_post_working("localhost:8877", "/resolve", request_data)
print("DNS API URL: gurt://" + dns_address + path)
if result.has("error"):
return {"error": result.error}
var response = await fetch_content_via_gurt_direct(dns_address, path)
if response.has("error"):
if "No response from GURT server" in response.error or "Failed to create GURT client" in response.error:
return {"error": "DNS server is not responding"}
else:
return {"error": "Failed to make DNS request"}
if not response.has("content"):
return {"error": "DNS server is not responding"}
var content = response.content
if content.is_empty():
return {"error": "DNS server is not responding"}
if not result.has("content"):
return {"error": "No content in DNS response"}
var content_str = result.content.get_string_from_utf8()
var json = JSON.new()
var parse_result = json.parse(content.get_string_from_utf8())
var parse_result = json.parse(content_str)
if parse_result != OK:
return {"error": "Invalid JSON response from DNS server"}
return {"error": "Invalid JSON in DNS response"}
var data = json.data
return json.data
# Check if the response indicates an error (like 404)
if data is Dictionary and data.has("error"):
return {"error": "Domain not found or not approved"}
static func fetch_full_domain_info(full_domain: String, record_type: String = "") -> Dictionary:
var request_data = {"domain": full_domain}
if not record_type.is_empty():
request_data["record_type"] = record_type
return data
var json_data = JSON.stringify(request_data)
var result = await fetch_dns_post_working("localhost:8877", "/resolve-full", json_data)
if result.has("error"):
return {"error": result.error}
if not result.has("content"):
return {"error": "No content in DNS response"}
var content_str = result.content.get_string_from_utf8()
var json = JSON.new()
var parse_result = json.parse(content_str)
if parse_result != OK:
return {"error": "Invalid JSON in DNS response"}
return json.data
static func fetch_dns_post_working(server: String, path: String, json_data: String) -> Dictionary:
var shared_result = {"finished": false}
var thread = Thread.new()
var mutex = Mutex.new()
var thread_func = func():
var local_result = {}
var client = GurtProtocolClient.new()
if not client.create_client(10):
local_result = {"error": "Failed to create client"}
else:
var url = "gurt://" + server + path
# Prepare request options
var options = {
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": json_data
}
var response = client.request(url, options)
client.disconnect()
if not response:
local_result = {"error": "No response from server"}
elif not response.is_success:
local_result = {"error": "Server error: " + str(response.status_code) + " " + str(response.status_message)}
else:
local_result = {"content": response.body}
mutex.lock()
shared_result.clear()
for key in local_result:
shared_result[key] = local_result[key]
shared_result["finished"] = true
mutex.unlock()
thread.start(thread_func)
# Non-blocking wait
while not shared_result.get("finished", false):
await Engine.get_main_loop().process_frame
thread.wait_to_finish()
mutex.lock()
var final_result = {}
for key in shared_result:
if key != "finished":
final_result[key] = shared_result[key]
mutex.unlock()
return final_result
static func fetch_content_via_gurt(ip: String, path: String = "/") -> Dictionary:
var client = GurtProtocolClient.new()
@@ -150,14 +252,10 @@ static func fetch_content_via_gurt_direct(address: String, path: String = "/") -
thread.start(thread_func)
var finished = false
while not finished:
# Non-blocking wait using signals instead of polling
while not shared_result.get("finished", false):
await Engine.get_main_loop().process_frame
OS.delay_msec(10)
mutex.lock()
finished = shared_result.get("finished", false)
mutex.unlock()
# Yield control back to the main thread without blocking delays
thread.wait_to_finish()
@@ -176,34 +274,125 @@ static func handle_gurt_domain(url: String) -> Dictionary:
return {"error": "Invalid domain format. Use: domain.tld or IP:port", "html": create_error_page("Invalid domain format. Use: domain.tld or IP:port")}
var target_address: String
var path = "/"
var path = parsed.get("path", "/")
if parsed.get("is_direct", false):
target_address = parsed.direct_address
else:
var domain_info = await fetch_domain_info(parsed.name, parsed.tld)
var domain_info: Dictionary
# Use the new full domain resolution for subdomains
if parsed.get("is_subdomain", false):
domain_info = await fetch_full_domain_info(parsed.full_domain)
else:
domain_info = await fetch_domain_info(parsed.name, parsed.tld)
if domain_info.has("error"):
return {"error": domain_info.error, "html": create_error_page(domain_info.error)}
target_address = domain_info.ip
# Process DNS records to find target address
var target_result = await resolve_target_address(domain_info, parsed.full_domain)
if target_result.has("error"):
return {"error": target_result.error, "html": create_error_page(target_result.error)}
target_address = target_result.address
# Cache the resolved address
var domain_key = parsed.full_domain
_dns_cache[domain_key] = target_address
var content_result = await fetch_content_via_gurt_direct(target_address, path)
if content_result.has("error"):
var error_msg = "Failed to fetch content from " + target_address + " via GURT protocol - " + content_result.error
var error_msg = "Failed to fetch content from " + target_address + path + " via GURT protocol - " + content_result.error
if content_result.has("content") and not content_result.content.is_empty():
return {"html": content_result.content, "display_url": parsed.display_url}
return {"error": error_msg, "html": create_error_page(error_msg)}
if not content_result.has("content"):
var error_msg = "No content received from " + target_address
var error_msg = "No content received from " + target_address + path
return {"error": error_msg, "html": create_error_page(error_msg)}
var html_content = content_result.content
if html_content.is_empty():
var error_msg = "Empty content received from " + target_address
var error_msg = "Empty content received from " + target_address + path
return {"error": error_msg, "html": create_error_page(error_msg)}
return {"html": html_content, "display_url": parsed.display_url}
static func resolve_target_address(domain_info: Dictionary, original_domain: String) -> Dictionary:
if not domain_info.has("records") or domain_info.records == null:
return {"error": "No DNS records found for domain"}
var records = domain_info.records
var max_cname_depth = 5 # Prevent infinite CNAME loops
var cname_depth = 0
# First pass: Look for direct A/AAAA records
var a_records = []
var aaaa_records = []
var cname_records = []
var ns_records = []
for record in records:
if not record.has("type") or not record.has("value"):
continue
match record.type:
"A":
a_records.append(record.value)
"AAAA":
aaaa_records.append(record.value)
"CNAME":
cname_records.append(record.value)
"NS":
ns_records.append(record.value)
# If we have direct A records, use the first one
if not a_records.is_empty():
return {"address": a_records[0]}
# If we have IPv6 AAAA records and no A records, we need to handle this
if not aaaa_records.is_empty() and a_records.is_empty():
return {"error": "Only IPv6 (AAAA) records found, but IPv4 required for GURT protocol"}
# Follow CNAME chain
if not cname_records.is_empty():
var current_cname = cname_records[0]
while cname_depth < max_cname_depth:
cname_depth += 1
# Try to resolve the CNAME target
var cname_info = await fetch_full_domain_info(current_cname, "A")
if cname_info.has("error"):
return {"error": "Failed to resolve CNAME target: " + current_cname + " - " + cname_info.error}
if not cname_info.has("records") or cname_info.records == null:
return {"error": "No records found for CNAME target: " + current_cname}
# Look for A records in the CNAME target
var found_next_cname = false
for record in cname_info.records:
if record.has("type") and record.type == "A" and record.has("value"):
return {"address": record.value}
elif record.has("type") and record.type == "CNAME" and record.has("value"):
# Another CNAME, continue the chain
current_cname = record.value
found_next_cname = true
break
if not found_next_cname:
# No more CNAMEs found, but also no A record
return {"error": "CNAME chain ended without A record for: " + current_cname}
return {"error": "CNAME chain too deep (max " + str(max_cname_depth) + " levels)"}
# If we have NS records, this indicates delegation
if not ns_records.is_empty():
return {"error": "Domain is delegated to nameservers: " + str(ns_records) + ". Cannot resolve directly."}
return {"error": "No A record found for domain"}
static func get_error_type(error_message: String) -> Dictionary:
if "DNS server is not responding" in error_message or "Domain not found" in error_message:
return {"code": "ERR_NAME_NOT_RESOLVED", "title": "This site can't be reached", "icon": "🌐"}

View File

@@ -57,7 +57,7 @@ func _get_font_weight_multiplier() -> float:
elif element_styles.has("font-extrabold"):
return 1.10
elif element_styles.has("font-bold"):
return 1.0
return 1.04
elif element_styles.has("font-semibold"):
return 1.06
elif element_styles.has("font-medium"):

View File

@@ -293,7 +293,7 @@ impl GurtServer {
}
async fn handle_connection(&self, mut stream: TcpStream, addr: SocketAddr) -> Result<()> {
let connection_result = timeout(self.connection_timeout, async {
// Remove timeout wrapper that causes connection aborts
self.handle_initial_handshake(&mut stream, addr).await?;
if let Some(tls_acceptor) = &self.tls_acceptor {
@@ -308,19 +308,10 @@ impl GurtServer {
warn!("No TLS configuration available, but handshake completed - this violates GURT protocol");
Err(GurtError::protocol("TLS is required after handshake but no TLS configuration available"))
}
}).await;
match connection_result {
Ok(result) => result,
Err(_) => {
warn!("Connection timeout for {}", addr);
Err(GurtError::timeout("Connection timeout"))
}
}
}
async fn handle_initial_handshake(&self, stream: &mut TcpStream, addr: SocketAddr) -> Result<()> {
let handshake_result = timeout(self.handshake_timeout, async {
// Remove timeout wrapper that causes connection aborts
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
@@ -356,15 +347,6 @@ impl GurtServer {
Err(GurtError::protocol("Server received response during handshake"))
}
}
}).await;
match handshake_result {
Ok(result) => result,
Err(_) => {
warn!("Handshake timeout for {}", addr);
Err(GurtError::timeout("Handshake timeout"))
}
}
}
async fn handle_tls_connection(&self, mut tls_stream: TlsStream<TcpStream>, addr: SocketAddr) -> Result<()> {
@@ -393,26 +375,17 @@ impl GurtServer {
(buffer.starts_with(b"{") && buffer.ends_with(b"}"));
if has_complete_message {
let process_result = timeout(self.request_timeout,
self.process_tls_message(&mut tls_stream, addr, &buffer)
).await;
match process_result {
Ok(Ok(())) => {
// Remove timeout wrapper that causes connection aborts
match self.process_tls_message(&mut tls_stream, addr, &buffer).await {
Ok(()) => {
debug!("Processed message from {} successfully", addr);
}
Ok(Err(e)) => {
Err(e) => {
error!("Encrypted message processing error from {}: {}", addr, e);
let error_response = GurtResponse::internal_server_error()
.with_string_body("Internal server error");
let _ = tls_stream.write_all(&error_response.to_bytes()).await;
}
Err(_) => {
warn!("Request timeout for {}", addr);
let timeout_response = GurtResponse::new(GurtStatusCode::Timeout)
.with_string_body("Request timeout");
let _ = tls_stream.write_all(&timeout_response.to_bytes()).await;
}
}
buffer.clear();