Merge pull request #2 from outpoot/dev

feat: Gurt CA
This commit is contained in:
Face
2025-08-22 18:42:04 +03:00
committed by GitHub
55 changed files with 5458 additions and 541 deletions

4
.gitignore vendored
View File

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

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

@@ -120,7 +120,7 @@
</div>
<div style="form-group">
<p>Name:</p>
<input id="record-name" type="text" style="form-input" placeholder="@, www, *" pattern="^(?:\*|@|((?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*))$" />
<input id="record-name" type="text" style="form-input" placeholder="@, www, *" />
</div>
<div style="form-group">
<p>Value:</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
@@ -125,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', {
@@ -221,11 +227,10 @@ end
local function addRecord(type, name, value, ttl)
hideError('record-error')
print('Adding DNS record: ' .. type .. ' ' .. name .. ' ' .. value)
local response = fetch('gurt://localhost:8877/domain/' .. domainName .. '/records', {
method = 'POST',
headers = {
headers = {
['Content-Type'] = 'application/json',
Authorization = 'Bearer ' .. authToken
},
@@ -237,30 +242,36 @@ local function addRecord(type, name, value, ttl)
})
})
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'
-- 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)
-- Render only the new record
renderRecords(true)
if response then
if response:ok() then
gurt.select('#record-name').value = ''
gurt.select('#record-value').value = ''
gurt.select('#record-ttl').value = ''
local newRecord = response:json()
if newRecord and newRecord.id then
table.insert(records, newRecord)
local wasEmpty = (#records == 1)
if wasEmpty then
renderRecords(false)
else
renderRecords(true)
end
else
loadRecords()
end
else
-- Server didn't return record details, reload to get the actual data
loadRecords()
local error = response:text()
showError('record-error', 'Failed to add record: ' .. error)
print('Failed to add DNS record: ' .. error)
end
else
local error = response:text()
showError('record-error', 'Failed to add record: ' .. error)
print('Failed to add DNS record: ' .. error)
showError('record-error', 'No response from server - connection failed')
print('Failed to add DNS record: No response')
end
end
local function logout()
@@ -273,15 +284,48 @@ 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'}
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' 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
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')
@@ -297,4 +341,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" />
@@ -49,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>

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)

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,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

@@ -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;

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

@@ -0,0 +1,172 @@
use anyhow::Result;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::x509::X509Req;
use openssl::x509::X509Name;
use openssl::hash::MessageDigest;
use std::process::Command;
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 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 mut san_builder = openssl::x509::extension::SubjectAlternativeName::new();
san_builder
.dns(domain)
.dns("localhost")
.ip("127.0.0.1");
if let Ok(public_ip) = get_public_ip() {
san_builder.ip(&public_ip);
}
let subject_alt_name = san_builder.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)?)
}
fn get_public_ip() -> Result<String, Box<dyn std::error::Error>> {
// Method 1: Check if we can get it from environment or interface
if let Ok(output) = Command::new("curl")
.args(&["-s", "--max-time", "5", "https://api.ipify.org"])
.output()
{
if output.status.success() {
let ip = String::from_utf8(output.stdout)?.trim().to_string();
if is_valid_ip(&ip) {
return Ok(ip);
}
}
}
// Method 2: Try ifconfig.me
if let Ok(output) = Command::new("curl")
.args(&["-s", "--max-time", "5", "https://ifconfig.me/ip"])
.output()
{
if output.status.success() {
let ip = String::from_utf8(output.stdout)?.trim().to_string();
if is_valid_ip(&ip) {
return Ok(ip);
}
}
}
// Method 3: Try to get from network interfaces
if let Ok(output) = Command::new("hostname")
.args(&["-I"])
.output()
{
if output.status.success() {
let ips = String::from_utf8(output.stdout)?;
for ip in ips.split_whitespace() {
if is_valid_ip(ip) && !ip.starts_with("127.") && !ip.starts_with("192.168.") && !ip.starts_with("10.") {
return Ok(ip.to_string());
}
}
}
}
Err("Could not determine public IP".into())
}
fn is_valid_ip(ip: &str) -> bool {
ip.split('.')
.count() == 4
&& ip.split('.')
.all(|part| part.parse::<u8>().is_ok())
}

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,31 +35,61 @@ impl EventHandler for BotHandler {
}
};
// 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);
// 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)
.execute(&self.pool)
.await
{
Ok(_) => {
// First, send ephemeral confirmation
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);
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) => {
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;
}
}
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_") {
@@ -116,32 +141,66 @@ impl EventHandler for BotHandler {
})
.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);
// 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)
.bind(domain_id)
.execute(&self.pool)
.await
{
Ok(_) => {
// First, send ephemeral confirmation
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);
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) => {
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;
}
}
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;
}
}
}
@@ -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

@@ -2,26 +2,25 @@ mod auth_routes;
mod helpers;
mod models;
mod routes;
mod ca;
use crate::{auth::jwt_middleware_gurt, config::Config, discord_bot};
use colored::Colorize;
use macros_rs::fmt::{crashln, string};
use std::{net::IpAddr, str::FromStr, sync::Arc, collections::HashMap};
use std::{sync::Arc, collections::HashMap};
use gurt::prelude::*;
use gurt::{GurtStatusCode, Route};
#[derive(Clone)]
pub(crate) struct AppState {
trusted: IpAddr,
config: Config,
db: sqlx::PgPool,
jwt_secret: String,
}
impl AppState {
pub fn new(trusted: IpAddr, config: Config, db: sqlx::PgPool, jwt_secret: String) -> Self {
pub fn new(config: Config, db: sqlx::PgPool, jwt_secret: String) -> Self {
Self {
trusted,
config,
db,
jwt_secret,
@@ -95,6 +94,12 @@ enum HandlerType {
DeleteDomain,
GetUserDomains,
CreateDomainRecord,
ResolveDomain,
ResolveFullDomain,
VerifyDomainOwnership,
RequestCertificate,
GetCertificate,
GetCaCertificate,
}
impl GurtHandler for AppHandler {
@@ -163,6 +168,12 @@ 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,
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();
@@ -185,11 +196,6 @@ impl GurtHandler for AppHandler {
pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
let config = Config::new().set_path(&cli.config).read();
let trusted_ip = match IpAddr::from_str(&config.server.address) {
Ok(addr) => addr,
Err(err) => crashln!("Cannot parse address.\n{}", string!(err).white()),
};
let db = match config.connect_to_db().await {
Ok(pool) => pool,
Err(err) => crashln!("Failed to connect to PostgreSQL database.\n{}", string!(err).white()),
@@ -203,7 +209,7 @@ pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
}
let jwt_secret = config.auth.jwt_secret.clone();
let app_state = AppState::new(trusted_ip, config.clone(), db, jwt_secret);
let app_state = AppState::new(config.clone(), db, jwt_secret);
let rate_limit_state = RateLimitState::new();
// Create GURT server
@@ -231,7 +237,13 @@ 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 })
.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

@@ -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>>,
}
@@ -82,13 +82,21 @@ 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>,
}
#[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>,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
mod config;
mod gurt_server;
mod secret;
mod auth;
mod discord_bot;
mod crypto;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{LogLevel, Verbosity};

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,136 @@
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)
- 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"`
## 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. 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]
```
## 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

View File

@@ -35,7 +35,11 @@ const config: Config = {
defaultLocale: 'en',
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

@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:github.com)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFHDCCAwSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA/MQswCQYDVQQGEwJVUzEX
MBUGA1UECgwOR3VydGVkIE5ldHdvcmsxFzAVBgNVBAMMDkd1cnRlZCBSb290IENB
MB4XDTI1MDgyMTE1MjgyM1oXDTM1MDgxOTE1MjgyM1owPzELMAkGA1UEBhMCVVMx
FzAVBgNVBAoMDkd1cnRlZCBOZXR3b3JrMRcwFQYDVQQDDA5HdXJ0ZWQgUm9vdCBD
QTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANYNLAnXNo8x9qzJbAwT
dDVC40XSfEVIBPWX4yBEKMSKefcUQy2ZBqzBSVVeig7q7OEPm29sL0XbSgxPN8nH
Pkg8ZfKhsDIuHGLeZbt1NAvc4mlMUHY5ebTMUaopldNJlKAKOJ+Xh6XHF3Tl4d3D
HnkdQv4s0wdfbI8Dem8G+JoqMu5Cn1BJcoB6vmmwH6/Fkq7qEdVe3WfKWflBQ7qk
rmj3hrKjKG62EsQKF+4JVPWY7RVG8rJukABakRndCKCM9te+XTIeollL/WvIcY4p
Ctf+6/p7FcnWQrDdcGwFmWpVj/SHGzgCi0PfTsI8V3vpCyBzIc2rZJvpLH8ndfUI
fNzYCAiRA4HUoXbyTvpMxJ3io4q9VZKuJ5mbe50NlJ/oiX2wFvosm5OMHUAk4tNJ
64jQLHTVrI/O+TKbLebKH9xEUCFOJpQX4rz4nzyRRdzM3C4qDZ4UTz3hAMeBus79
jJtZj26T2O7zYweihWhPFkatvick66aDhD5jeQLnPp/w4mY4iuZMf3tb2L+Py/BR
k8LHg9xTFL79lwpelwbLSVOdLXXQXSRDx6eF0qG4dDALAlbEBYCrK8wjQqvH3/Fg
EJbG9RTgywi6UgAy+jVdYFtW5+2No1HTyqELzq0OeOInzJf1xVM8IAP1KFkQF3V2
ofIc4Uz4fF2mOpzJeeOkBKU1AgMBAAGjIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQCkIajpq/McB5w8S2CdC4IF
x63BQ2Zje8PAd0LjbtHulH4RoZQzW8+hHJgb13KfOg0MLZ7iEy0gSS/D2eF4uJpd
NKgT8ZuG1v+e/OWsxRoonNpopz2dHt8qstRiqRlKZ1/45pXNwAM+ztWuRR2AIHB1
bSStCShLArdB80/42OXK7Uq1CzNN8ikw4JKKdU+JP4TrCLIBNlDYq5hcFCjb+6f2
fmJ5+VjZVx3yXV281Q1K2enMo0ACzPiD+1hgnms144hhbBqyP7rrQcnN/Z0Vq65U
nFNQT5yU6KYuyPbajYxtpr7jKwJDsPJMa0pOW4H93IN0+jdqIk5vr8zE7PHztOIp
KB+gMyTbeWx6hVmf6eRDVd56uibS5s+QrESQ6FWjO2Ns73qg9/vhW81JtTQ5NRF9
YSKy3YHIKN3+bmUPOVp6rhb+xU2QaI7CQxjXlDt3Y3+evFe2oGyG/N439z09+az5
A1J4f5mWP4+n/t8k75Z6PuVpOAUsiklJIcTOpRnYRlW+U+md94MsYD60ITWSgiad
A7Uu3uoyS+wN8W1yNmPaVci2L19rgKc9ZMXCPFj6x6QiiR6fG7/7M8WGOR6Lx1n0
9DcYTpcbYAdSufUSUtd9isjR1jzTHeIYQ9rRfdlQaOw3lnIVG0H9wVSBcAzMeSnd
tUnu0gVTdnuUfjO1Te86fA==
-----END CERTIFICATE-----

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

@@ -0,0 +1,49 @@
extends RefCounted
class_name CertificateManager
static var trusted_ca_certificates: Array[String] = []
static var ca_cache: Dictionary = {}
static func fetch_cert_via_http(url: String) -> String:
var http_request = HTTPRequest.new()
var main_scene = Engine.get_main_loop().current_scene
if not main_scene:
return ""
main_scene.add_child(http_request)
var error = http_request.request(url)
if error != OK:
http_request.queue_free()
return ""
var response = await http_request.request_completed
http_request.queue_free()
var result = response[0]
var response_code = response[1]
var body = response[3]
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
return ""
return body.get_string_from_utf8()
static func initialize():
load_builtin_ca()
print("📋 Certificate Manager initialized with ", trusted_ca_certificates.size(), " trusted CAs")
static func load_builtin_ca():
var ca_file = FileAccess.open("res://Assets/gurted-ca.crt", FileAccess.READ)
if ca_file:
var ca_cert_pem = ca_file.get_as_text()
ca_file.close()
if not ca_cert_pem.is_empty():
trusted_ca_certificates.append(ca_cert_pem)
print("✅ Loaded built-in GURT CA certificate")
else:
print("⚠️ Built-in CA certificate not yet configured")
else:
print("❌ Could not load built-in CA certificate")

View File

@@ -0,0 +1 @@
uid://bhnsb8ttn6f7n

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,27 +18,63 @@ 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 {}
return {
"name": parts[0],
"tld": parts[1],
"display_url": domain_part,
"is_direct": false
}
# Support subdomains (e.g., api.blog.example.com)
if parts.size() == 2:
return {
"name": parts[0],
"tld": parts[1],
"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:
var parts = address.split(".")
@@ -51,44 +90,113 @@ 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"
static func fetch_domain_info(name: String, tld: String) -> Dictionary:
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
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
# 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"}
var json_data = JSON.stringify(request_data)
var result = await fetch_dns_post_working("localhost:8877", "/resolve-full", json_data)
return 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()
for ca_cert in CertificateManager.trusted_ca_certificates:
client.add_ca_certificate(ca_cert)
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()
for ca_cert in CertificateManager.trusted_ca_certificates:
client.add_ca_certificate(ca_cert)
if not client.create_client(30):
return {"error": "Failed to create GURT client"}
@@ -117,6 +225,9 @@ static func fetch_content_via_gurt_direct(address: String, path: String = "/") -
var local_result = {}
var client = GurtProtocolClient.new()
for ca_cert in CertificateManager.trusted_ca_certificates:
client.add_ca_certificate(ca_cert)
if not client.create_client(10):
local_result = {"error": "Failed to create GURT client"}
else:
@@ -150,14 +261,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 +283,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

@@ -45,31 +45,31 @@ func _auto_resize_to_content():
await get_tree().process_frame
var content_height = get_content_height()
var explicit_height = custom_minimum_size.y if custom_minimum_size.y > 0 else null
var final_height = explicit_height if explicit_height != null else max(content_height, min_height)
var explicit_height = custom_minimum_size.y if custom_minimum_size.y > 0 else 0.0
var final_height = explicit_height if explicit_height > 0 else max(content_height, min_height)
custom_minimum_size = Vector2(desired_width, final_height)
queue_redraw()
func _get_font_weight_multiplier() -> float:
if element_styles.has("font-black"):
return 1.12
elif element_styles.has("font-extrabold"):
return 1.10
elif element_styles.has("font-bold"):
return 1.0
elif element_styles.has("font-semibold"):
return 1.06
elif element_styles.has("font-medium"):
return 1.03
elif element_styles.has("font-light"):
return 0.98
elif element_styles.has("font-extralight") or element_styles.has("font-thin"):
return 0.95
#if element_styles.has("font-black"):
# return 1.12
#elif element_styles.has("font-extrabold"):
# return 1.10
#elif element_styles.has("font-bold"):
# return 1.04
#elif element_styles.has("font-semibold"):
# return 1.06
#elif element_styles.has("font-medium"):
# return 1.03
#elif element_styles.has("font-light"):
# return 0.98
#elif element_styles.has("font-extralight") or element_styles.has("font-thin"):
# return 0.95
var text_content = get_parsed_text()
#var text_content = get_parsed_text()
if text_content.contains("[b]"):
return 1.08
#if text_content.contains("[b]"):
# return 1.08
return 1.0

View File

@@ -1,29 +1,31 @@
class_name LuaNetworkUtils
extends RefCounted
static var gurt_client: GurtProtocolClient = null
static var current_domain: String = ""
static var client_last_used: int = 0
static var client_timeout_ms: int = 30000 # 30 seconds timeout for idle connections
static func setup_network_api(vm: LuauVM):
vm.lua_pushcallable(_lua_fetch_handler, "fetch")
vm.lua_setglobal("fetch")
static func resolve_fetch_url(url: String) -> String:
# If URL is already absolute, return as-is
if url.begins_with("http://") or url.begins_with("https://") or url.begins_with("gurt://"):
return url
# Get current domain from main scene
var main_node = Engine.get_main_loop().current_scene
var current_domain = ""
if main_node and main_node.has_method("get_current_url"):
current_domain = main_node.get_current_url()
# If no current domain, default to gurt:// protocol for relative URLs
if current_domain.is_empty():
if url.begins_with("/"):
return "gurt://" + url.substr(1)
else:
return "gurt://" + url
# Use URLUtils to resolve relative URL against current domain
return URLUtils.resolve_url(current_domain, url)
static func _lua_fetch_handler(vm: LuauVM) -> int:
@@ -272,7 +274,36 @@ static func make_http_request(url: String, method: String, headers: PackedString
http_client.close()
return response_data
static var _gurt_client: GurtProtocolClient = null
static func cleanup_idle_client():
if gurt_client != null and Time.get_ticks_msec() - client_last_used > client_timeout_ms:
gurt_client.disconnect()
gurt_client = null
current_domain = ""
static func get_or_create_gurt_client(domain: String) -> GurtProtocolClient:
cleanup_idle_client()
if gurt_client != null and current_domain == domain:
client_last_used = Time.get_ticks_msec()
return gurt_client
if gurt_client != null:
gurt_client.disconnect()
gurt_client = null
gurt_client = GurtProtocolClient.new()
for ca_cert in CertificateManager.trusted_ca_certificates:
gurt_client.add_ca_certificate(ca_cert)
if not gurt_client.create_client(10):
gurt_client = null
current_domain = ""
return null
current_domain = domain
client_last_used = Time.get_ticks_msec()
return gurt_client
static func make_gurt_request(url: String, method: String, headers: PackedStringArray, body: String) -> Dictionary:
var response_data = {
@@ -282,15 +313,17 @@ static func make_gurt_request(url: String, method: String, headers: PackedString
"body": ""
}
# Reuse existing client or create new one
if _gurt_client == null:
_gurt_client = GurtProtocolClient.new()
if not _gurt_client.create_client(10):
response_data.status = 0
response_data.status_text = "Connection Failed"
return response_data
var domain_part = url.replace("gurt://", "")
if domain_part.contains("/"):
domain_part = domain_part.split("/")[0]
if domain_part.contains(":"):
domain_part = domain_part.split(":")[0]
var client = _gurt_client
var client = get_or_create_gurt_client(domain_part)
if client == null:
response_data.status = 0
response_data.status_text = "Connection Failed"
return response_data
# Convert headers array to dictionary
var headers_dict = {}
@@ -312,9 +345,6 @@ static func make_gurt_request(url: String, method: String, headers: PackedString
var response = client.request(url, options)
# Keep connection alive for reuse instead of disconnecting after every request
# client.disconnect()
if not response:
response_data.status = 0
response_data.status_text = "No Response"
@@ -331,3 +361,9 @@ static func make_gurt_request(url: String, method: String, headers: PackedString
response_data.body = str(body_content)
return response_data
static func cleanup_connections():
if gurt_client != null:
gurt_client.disconnect()
gurt_client = null
current_domain = ""

View File

@@ -53,6 +53,8 @@ func _ready():
ProjectSettings.set_setting("display/window/size/min_height", MIN_SIZE.y)
DisplayServer.window_set_min_size(MIN_SIZE)
CertificateManager.initialize()
call_deferred("render")
var current_domain = "" # Store current domain for display

View File

@@ -31,35 +31,34 @@ Gurty uses a TOML configuration file to manage server settings. The `gurty.templ
## Setup for Production
For production deployments, you'll need to generate your own certificates since traditional Certificate Authorities don't support custom protocols:
For production deployments, you can use the Gurted Certificate Authority to get proper TLS certificates:
1. **Generate production certificates with OpenSSL:**
1. **Install the Gurted CA CLI:**
🔗 https://gurted.com/download
2. **Request a certificate for your domain:**
```bash
# Generate private key
openssl genpkey -algorithm RSA -out gurt-server.key -pkcs8 -v
# Generate certificate signing request
openssl req -new -key gurt-server.key -out gurt-server.csr
# Generate self-signed certificate (valid for 365 days)
openssl x509 -req -days 365 -in gurt-server.csr -signkey gurt-server.key -out gurt-server.crt
# Or generate both key and certificate in one step
openssl req -x509 -newkey rsa:4096 -keyout gurt-server.key -out gurt-server.crt -days 365 -nodes
gurtca request yourdomain.web --output ./certs
```
2. **Copy the configuration template and customize:**
3. **Follow the DNS challenge instructions:**
When prompted, add the TXT record to your domain:
- Go to gurt://localhost:8877 (or your DNS server)
- Login and navigate to your domain
- Add a TXT record with:
- Name: `_gurtca-challenge`
- Value: (provided by the CLI tool)
- Press Enter to continue verification
4. **Copy the configuration template and customize:**
```bash
cp gurty.template.toml gurty.toml
```
3. **Deploy with production certificates and configuration:**
5. **Deploy with CA-issued certificates:**
```bash
gurty serve --config gurty.toml
```
Or specify certificates explicitly:
```bash
gurty serve --cert gurt-server.crt --key gurt-server.key --config gurty.toml
gurty serve --cert ./certs/yourdomain.web.crt --key ./certs/yourdomain.web.key --config gurty.toml
```
## Development Environment Setup

View File

@@ -17,6 +17,7 @@ struct GurtProtocolClient {
client: Arc<RefCell<Option<GurtClient>>>,
runtime: Arc<RefCell<Option<Runtime>>>,
ca_certificates: Arc<RefCell<Vec<String>>>,
}
#[derive(GodotClass)]
@@ -94,6 +95,15 @@ struct GurtProtocolServer {
#[godot_api]
impl GurtProtocolClient {
fn init(base: Base<RefCounted>) -> Self {
Self {
base,
client: Arc::new(RefCell::new(None)),
runtime: Arc::new(RefCell::new(None)),
ca_certificates: Arc::new(RefCell::new(Vec::new())),
}
}
#[signal]
fn request_completed(response: Gd<GurtGDResponse>);
@@ -110,6 +120,9 @@ impl GurtProtocolClient {
let mut config = GurtClientConfig::default();
config.request_timeout = tokio::time::Duration::from_secs(timeout_seconds as u64);
// Add custom CA certificates
config.custom_ca_certificates = self.ca_certificates.borrow().clone();
let client = GurtClient::with_config(config);
*self.runtime.borrow_mut() = Some(runtime);
@@ -228,6 +241,21 @@ impl GurtProtocolClient {
gurt::DEFAULT_PORT as i32
}
#[func]
fn add_ca_certificate(&self, cert_pem: GString) {
self.ca_certificates.borrow_mut().push(cert_pem.to_string());
}
#[func]
fn clear_ca_certificates(&self) {
self.ca_certificates.borrow_mut().clear();
}
#[func]
fn get_ca_certificate_count(&self) -> i32 {
self.ca_certificates.borrow().len() as i32
}
fn convert_response(&self, response: GurtResponse) -> Gd<GurtGDResponse> {
let mut gd_response = GurtGDResponse::new_gd();

1744
protocol/gurtca/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[package]
name = "gurtca"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "gurtca"
path = "src/main.rs"
[dependencies]
clap = { version = "4.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
gurt = { path = "../library" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
openssl = "0.10"
base64 = "0.22"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }

View File

View File

@@ -0,0 +1,49 @@
use anyhow::Result;
use crate::client::{Challenge, GurtCAClient};
pub async fn complete_dns_challenge(challenge: &Challenge, client: &GurtCAClient) -> Result<()> {
println!("Please add this TXT record to your domain:");
println!(" 1. Go to gurt://dns.web (or your DNS server)");
println!(" 2. Login and navigate to your domain: {}", challenge.domain);
println!(" 3. Add TXT record:");
println!(" Name: _gurtca-challenge");
println!(" Value: {}", challenge.verification_data);
println!(" 4. Press Enter when ready...");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
println!("🔍 Verifying DNS record...");
if verify_dns_txt_record(&challenge.domain, &challenge.verification_data, client).await? {
println!("✅ DNS challenge completed successfully!");
Ok(())
} else {
anyhow::bail!("❌ DNS verification failed. Make sure the TXT record is correctly set.");
}
}
async fn verify_dns_txt_record(domain: &str, expected_value: &str, client: &GurtCAClient) -> Result<bool> {
let request = serde_json::json!({
"domain": format!("_gurtca-challenge.{}", domain),
"record_type": "TXT"
});
let response = client
.post_json("gurt://localhost:8877/resolve-full", &request)
.await?;
if response.is_success() {
let dns_response: serde_json::Value = serde_json::from_slice(&response.body)?;
if let Some(records) = dns_response["records"].as_array() {
for record in records {
if record["type"] == "TXT" && record["value"] == expected_value {
return Ok(true);
}
}
}
}
Ok(false)
}

View File

@@ -0,0 +1,147 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use gurt::prelude::*;
pub struct GurtCAClient {
ca_url: String,
gurt_client: GurtClient,
}
#[derive(Serialize, Deserialize)]
pub struct CertificateRequest {
pub domain: String,
pub csr: String,
pub challenge_type: String,
}
#[derive(Serialize, Deserialize)]
pub struct Challenge {
pub token: String,
pub challenge_type: String,
pub domain: String,
pub verification_data: String,
}
#[derive(Serialize, Deserialize)]
pub struct Certificate {
pub cert_pem: String,
pub chain_pem: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
impl GurtCAClient {
pub fn new(ca_url: String) -> Result<Self> {
let gurt_client = GurtClient::new();
Ok(Self {
ca_url,
gurt_client,
})
}
pub async fn new_with_ca_discovery(ca_url: String) -> Result<Self> {
println!("🔍 Attempting to connect with system CA trust store...");
let test_client = Self::new(ca_url.clone())?;
match test_client.test_connection().await {
Ok(_) => {
println!("✅ Connection successful with system CA trust store");
return Ok(test_client);
}
Err(e) => {
if e.to_string().contains("UnknownIssuer") {
anyhow::bail!("Custom CA certificate required - server not trusted by system")
} else {
return Err(e);
}
}
}
}
async fn test_connection(&self) -> Result<()> {
// Try a simple request to test if connection works
let _response = self.gurt_client
.get(&format!("{}/ca/root", self.ca_url))
.await?;
Ok(())
}
pub async fn fetch_ca_certificate(&self) -> Result<String> {
let response = self.gurt_client
.get(&format!("{}/ca/root", self.ca_url))
.await?;
if response.is_success() {
let ca_cert = response.text()?;
// Basic validation that this looks like a PEM certificate
if ca_cert.contains("BEGIN CERTIFICATE") && ca_cert.contains("END CERTIFICATE") {
Ok(ca_cert)
} else {
anyhow::bail!("Invalid CA certificate format received")
}
} else {
anyhow::bail!("Failed to fetch CA certificate: HTTP {}", response.status_code)
}
}
pub async fn verify_domain_exists(&self, domain: &str) -> Result<bool> {
let response = self.gurt_client
.get(&format!("{}/verify-ownership/{}", self.ca_url, domain))
.await?;
if response.is_success() {
let result: serde_json::Value = serde_json::from_slice(&response.body)?;
Ok(result["exists"].as_bool().unwrap_or(false))
} else {
Ok(false)
}
}
pub async fn request_certificate(&self, domain: &str, csr: &str) -> Result<Challenge> {
let request = CertificateRequest {
domain: domain.to_string(),
csr: csr.to_string(),
challenge_type: "dns".to_string(),
};
let response = self.gurt_client
.post_json(&format!("{}/ca/request-certificate", self.ca_url), &request)
.await?;
if response.is_success() {
let challenge: Challenge = serde_json::from_slice(&response.body)?;
Ok(challenge)
} else {
let error_text = response.text()?;
anyhow::bail!("Certificate request failed: {}", error_text)
}
}
pub async fn poll_certificate(&self, challenge_token: &str) -> Result<Certificate> {
for _ in 0..60 {
let response = self.gurt_client
.get(&format!("{}/ca/certificate/{}", self.ca_url, challenge_token))
.await?;
if response.is_success() {
let body_text = response.text()?;
if body_text.trim().is_empty() {
// Empty response, certificate not ready yet
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
let cert: Certificate = serde_json::from_str(&body_text)?;
return Ok(cert);
} else if response.status_code == 202 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
} else {
let error_text = response.text()?;
anyhow::bail!("Certificate polling failed: {}", error_text);
}
}
anyhow::bail!("Certificate issuance timed out")
}
}

View File

@@ -0,0 +1,32 @@
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_key_and_csr(domain: &str) -> Result<(String, String)> {
let rsa = Rsa::generate(2048)?;
let private_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", domain)?;
let name = name_builder.build();
let mut req_builder = X509Req::builder()?;
req_builder.set_subject_name(&name)?;
req_builder.set_pubkey(&private_key)?;
req_builder.sign(&private_key, MessageDigest::sha256())?;
let csr = req_builder.build();
let private_key_pem = private_key.private_key_to_pem_pkcs8()?;
let csr_pem = csr.to_pem()?;
Ok((
String::from_utf8(private_key_pem)?,
String::from_utf8(csr_pem)?
))
}

111
protocol/gurtca/src/main.rs Normal file
View File

@@ -0,0 +1,111 @@
use clap::{Parser, Subcommand};
use anyhow::Result;
mod challenges;
mod crypto;
mod client;
#[derive(Parser)]
#[command(name = "gurtca")]
#[command(about = "Gurted Certificate Authority CLI - Get TLS certificates for your domains")]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, default_value = "gurt://localhost:8877")]
ca_url: String,
}
#[derive(Subcommand)]
enum Commands {
Request {
domain: String,
#[arg(long, default_value = "./certs")]
output: String,
},
GetCa {
#[arg(long, default_value = "./ca.crt")]
output: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let client = client::GurtCAClient::new_with_ca_discovery(cli.ca_url).await?;
match cli.command {
Commands::Request { domain, output } => {
println!("🔐 Requesting certificate for: {}", domain);
request_certificate(&client, &domain, &output).await?;
},
Commands::GetCa { output } => {
println!("📋 Fetching CA certificate from server...");
get_ca_certificate(&client, &output).await?;
},
}
Ok(())
}
async fn request_certificate(
client: &client::GurtCAClient,
domain: &str,
output_dir: &str
) -> Result<()> {
println!("🔍 Verifying domain exists...");
if !client.verify_domain_exists(domain).await? {
anyhow::bail!("❌ Domain does not exist or is not approved: {}", domain);
}
println!("🔑 Generating key pair...");
let (private_key, csr) = crypto::generate_key_and_csr(domain)?;
println!("📝 Submitting certificate request...");
let challenge = client.request_certificate(domain, &csr).await?;
println!("🧩 Completing DNS challenge...");
challenges::complete_dns_challenge(&challenge, client).await?;
println!("⏳ Waiting for certificate issuance...");
let certificate = client.poll_certificate(&challenge.token).await?;
println!("💾 Saving certificate files...");
std::fs::create_dir_all(output_dir)?;
std::fs::write(
format!("{}/{}.crt", output_dir, domain),
certificate.cert_pem
)?;
std::fs::write(
format!("{}/{}.key", output_dir, domain),
private_key
)?;
println!("✅ Certificate successfully issued for: {}", domain);
println!("📁 Files saved to: {}", output_dir);
println!(" - Certificate: {}/{}.crt", output_dir, domain);
println!(" - Private Key: {}/{}.key", output_dir, domain);
Ok(())
}
async fn get_ca_certificate(
client: &client::GurtCAClient,
output_path: &str
) -> Result<()> {
let ca_cert = client.fetch_ca_certificate().await?;
std::fs::write(output_path, &ca_cert)?;
println!("✅ CA certificate saved to: {}", output_path);
println!("💡 To trust this CA system-wide:");
println!(" Windows: Import {} into 'Trusted Root Certification Authorities'", output_path);
println!(" macOS: sudo security add-trusted-cert -d -r trustRoot -k /System/Library/Keychains/SystemRootCertificates.keychain {}", output_path);
println!(" Linux: Copy {} to /usr/local/share/ca-certificates/ and run sudo update-ca-certificates", output_path);
Ok(())
}

View File

@@ -23,6 +23,7 @@ pub struct GurtClientConfig {
pub max_redirects: usize,
pub enable_connection_pooling: bool,
pub max_connections_per_host: usize,
pub custom_ca_certificates: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -46,6 +47,7 @@ impl Default for GurtClientConfig {
max_redirects: 5,
enable_connection_pooling: true,
max_connections_per_host: 4,
custom_ca_certificates: Vec::new(),
}
}
}
@@ -53,21 +55,18 @@ impl Default for GurtClientConfig {
#[derive(Debug)]
enum Connection {
Plain(TcpStream),
Tls(tokio_rustls::client::TlsStream<TcpStream>),
}
impl Connection {
async fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
match self {
Connection::Plain(stream) => stream.read(buf).await.map_err(|e| GurtError::connection(e.to_string())),
Connection::Tls(stream) => stream.read(buf).await.map_err(|e| GurtError::connection(e.to_string())),
}
}
async fn write_all(&mut self, buf: &[u8]) -> Result<()> {
match self {
Connection::Plain(stream) => stream.write_all(buf).await.map_err(|e| GurtError::connection(e.to_string())),
Connection::Tls(stream) => stream.write_all(buf).await.map_err(|e| GurtError::connection(e.to_string())),
}
}
}
@@ -81,10 +80,6 @@ impl PooledConnection {
fn new(stream: TcpStream) -> Self {
Self { connection: Connection::Plain(stream) }
}
fn with_tls(stream: tokio_rustls::client::TlsStream<TcpStream>) -> Self {
Self { connection: Connection::Tls(stream) }
}
}
pub struct GurtClient {
@@ -257,7 +252,6 @@ impl GurtClient {
let tcp_stream = match plain_conn.connection {
Connection::Plain(stream) => stream,
_ => return Err(GurtError::protocol("Expected plain connection for handshake")),
};
self.upgrade_to_tls(tcp_stream, host).await
@@ -275,8 +269,27 @@ impl GurtClient {
added += 1;
}
}
for ca_cert_pem in &self.config.custom_ca_certificates {
let mut pem_bytes = ca_cert_pem.as_bytes();
let cert_iter = rustls_pemfile::certs(&mut pem_bytes);
for cert_result in cert_iter {
match cert_result {
Ok(cert) => {
if root_store.add(cert).is_ok() {
added += 1;
debug!("Added custom CA certificate");
}
}
Err(e) => {
debug!("Failed to parse CA certificate: {}", e);
}
}
}
}
if added == 0 {
return Err(GurtError::crypto("No valid system certificates found".to_string()));
return Err(GurtError::crypto("No valid certificates found (system or custom)".to_string()));
}
let mut client_config = TlsClientConfig::builder()

View File

@@ -7,7 +7,7 @@ use crate::{
};
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{timeout, Duration};
use tokio::time::Duration;
use tokio_rustls::{TlsAcceptor, server::TlsStream};
use rustls::pki_types::CertificateDer;
use std::collections::HashMap;
@@ -293,76 +293,56 @@ impl GurtServer {
}
async fn handle_connection(&self, mut stream: TcpStream, addr: SocketAddr) -> Result<()> {
let connection_result = timeout(self.connection_timeout, async {
self.handle_initial_handshake(&mut stream, addr).await?;
if let Some(tls_acceptor) = &self.tls_acceptor {
info!("Upgrading connection to TLS for {}", addr);
let tls_stream = tls_acceptor.accept(stream).await
.map_err(|e| GurtError::crypto(format!("TLS upgrade failed: {}", e)))?;
info!("TLS upgrade completed for {}", addr);
self.handle_tls_connection(tls_stream, addr).await
} else {
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;
self.handle_initial_handshake(&mut stream, addr).await?;
match connection_result {
Ok(result) => result,
Err(_) => {
warn!("Connection timeout for {}", addr);
Err(GurtError::timeout("Connection timeout"))
}
if let Some(tls_acceptor) = &self.tls_acceptor {
info!("Upgrading connection to TLS for {}", addr);
let tls_stream = tls_acceptor.accept(stream).await
.map_err(|e| GurtError::crypto(format!("TLS upgrade failed: {}", e)))?;
info!("TLS upgrade completed for {}", addr);
self.handle_tls_connection(tls_stream, addr).await
} else {
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"))
}
}
async fn handle_initial_handshake(&self, stream: &mut TcpStream, addr: SocketAddr) -> Result<()> {
let handshake_result = timeout(self.handshake_timeout, async {
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
loop {
let bytes_read = stream.read(&mut temp_buffer).await?;
if bytes_read == 0 {
return Err(GurtError::connection("Connection closed during handshake"));
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
let body_separator = BODY_SEPARATOR.as_bytes();
if buffer.windows(body_separator.len()).any(|w| w == body_separator) {
break;
}
if buffer.len() > MAX_MESSAGE_SIZE {
return Err(GurtError::protocol("Handshake message too large"));
}
}
let message = GurtMessage::parse_bytes(&buffer)?;
match message {
GurtMessage::Request(request) => {
if request.method == GurtMethod::HANDSHAKE {
self.send_handshake_response(stream, addr, &request).await
} else {
Err(GurtError::protocol("First message must be HANDSHAKE"))
}
}
GurtMessage::Response(_) => {
Err(GurtError::protocol("Server received response during handshake"))
}
}
}).await;
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
match handshake_result {
Ok(result) => result,
Err(_) => {
warn!("Handshake timeout for {}", addr);
Err(GurtError::timeout("Handshake timeout"))
loop {
let bytes_read = stream.read(&mut temp_buffer).await?;
if bytes_read == 0 {
return Err(GurtError::connection("Connection closed during handshake"));
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
let body_separator = BODY_SEPARATOR.as_bytes();
if buffer.windows(body_separator.len()).any(|w| w == body_separator) {
break;
}
if buffer.len() > MAX_MESSAGE_SIZE {
return Err(GurtError::protocol("Handshake message too large"));
}
}
let message = GurtMessage::parse_bytes(&buffer)?;
match message {
GurtMessage::Request(request) => {
if request.method == GurtMethod::HANDSHAKE {
self.send_handshake_response(stream, addr, &request).await
} else {
Err(GurtError::protocol("First message must be HANDSHAKE"))
}
}
GurtMessage::Response(_) => {
Err(GurtError::protocol("Server received response during handshake"))
}
}
}
@@ -393,26 +373,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();