From 00309149d49ed3681f2a98ad6de0b1d5f2818028 Mon Sep 17 00:00:00 2001
From: Face <69168154+face-hh@users.noreply.github.com>
Date: Fri, 22 Aug 2025 17:31:54 +0300
Subject: [PATCH] CA
---
.gitignore | 3 +-
dns/Cargo.lock | 60 +
dns/Cargo.toml | 4 +
dns/frontend/domain.html | 2 -
dns/frontend/domain.lua | 37 +-
dns/frontend/index.html | 2 +-
dns/frontend/signup.html | 68 +
dns/frontend/signup.lua | 96 +
.../005_add_certificate_challenges.sql | 33 +
.../007_cleanup_invalid_records.sql | 7 +
dns/migrations/008_add_ca_storage.sql | 8 +
dns/migrations/009_add_csr_to_challenges.sql | 2 +
dns/src/crypto.rs | 114 ++
dns/src/gurt_server.rs | 15 +-
dns/src/gurt_server/ca.rs | 45 +
dns/src/gurt_server/models.rs | 2 +-
dns/src/gurt_server/routes.rs | 233 ++-
dns/src/main.rs | 1 +
flumi/.claude/settings.local.json | 10 -
flumi/Assets/gurted-ca.crt | 30 +
flumi/Scripts/CertificateManager.gd | 49 +
flumi/Scripts/CertificateManager.gd.uid | 1 +
flumi/Scripts/GurtProtocol.gd | 9 +
flumi/Scripts/Utils/Lua/Network.gd | 29 +-
flumi/Scripts/main.gd | 2 +
.../~gdluau.windows.template_debug.x86_64.dll | Bin 1184768 -> 0 bytes
...windows.template_release.double.x86_64.dll | Bin 421376 -> 0 bytes
.../gurt-protocol/bin/windows/gurt_godot.dll | Bin 4697088 -> 4702720 bytes
.../gurt-protocol/bin/windows/~gurt_godot.dll | Bin 4697088 -> 0 bytes
protocol/cli/README.md | 39 +-
protocol/gdextension/src/lib.rs | 28 +
protocol/gurtca/Cargo.lock | 1744 +++++++++++++++++
protocol/gurtca/Cargo.toml | 20 +
protocol/gurtca/src/ca.rs | 0
protocol/gurtca/src/challenges.rs | 52 +
protocol/gurtca/src/client.rs | 167 ++
protocol/gurtca/src/crypto.rs | 32 +
protocol/gurtca/src/main.rs | 118 ++
protocol/library/src/client.rs | 23 +-
39 files changed, 3001 insertions(+), 84 deletions(-)
create mode 100644 dns/frontend/signup.html
create mode 100644 dns/frontend/signup.lua
create mode 100644 dns/migrations/005_add_certificate_challenges.sql
create mode 100644 dns/migrations/007_cleanup_invalid_records.sql
create mode 100644 dns/migrations/008_add_ca_storage.sql
create mode 100644 dns/migrations/009_add_csr_to_challenges.sql
create mode 100644 dns/src/crypto.rs
create mode 100644 dns/src/gurt_server/ca.rs
delete mode 100644 flumi/.claude/settings.local.json
create mode 100644 flumi/Assets/gurted-ca.crt
create mode 100644 flumi/Scripts/CertificateManager.gd
create mode 100644 flumi/Scripts/CertificateManager.gd.uid
delete mode 100644 flumi/addons/gdluau/bin/windows/~gdluau.windows.template_debug.x86_64.dll
delete mode 100644 flumi/addons/godot-flexbox/bin/windows/~godot-flexbox.windows.template_release.double.x86_64.dll
delete mode 100644 flumi/addons/gurt-protocol/bin/windows/~gurt_godot.dll
create mode 100644 protocol/gurtca/Cargo.lock
create mode 100644 protocol/gurtca/Cargo.toml
create mode 100644 protocol/gurtca/src/ca.rs
create mode 100644 protocol/gurtca/src/challenges.rs
create mode 100644 protocol/gurtca/src/client.rs
create mode 100644 protocol/gurtca/src/crypto.rs
create mode 100644 protocol/gurtca/src/main.rs
diff --git a/.gitignore b/.gitignore
index 92a146e..bb782b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
*target*
*.pem
-*gurty.toml
\ No newline at end of file
+gurty.toml
+certs
\ No newline at end of file
diff --git a/dns/Cargo.lock b/dns/Cargo.lock
index 2685d98..82947c7 100644
--- a/dns/Cargo.lock
+++ b/dns/Cargo.lock
@@ -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]]
diff --git a/dns/Cargo.toml b/dns/Cargo.toml
index ddd75d3..17c96dc 100644
--- a/dns/Cargo.toml
+++ b/dns/Cargo.toml
@@ -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"
diff --git a/dns/frontend/domain.html b/dns/frontend/domain.html
index 1270cc2..43be10e 100644
--- a/dns/frontend/domain.html
+++ b/dns/frontend/domain.html
@@ -116,7 +116,6 @@
AAAA
CNAME
TXT
- NS
@@ -131,7 +130,6 @@
Enter an IPv6 address (e.g., 2001:db8::1)
Enter a domain name (e.g., example.com)
Enter any text content
- Enter a nameserver domain (e.g., ns1.example.com)
diff --git a/dns/frontend/domain.lua b/dns/frontend/domain.lua
index 19c49ed..986cc9a 100644
--- a/dns/frontend/domain.lua
+++ b/dns/frontend/domain.lua
@@ -131,7 +131,7 @@ renderRecords = function(appendOnly)
local typeCell = gurt.create('div', { text = record.type, style = 'font-bold' })
local nameCell = gurt.create('div', { text = record.name or '@' })
local valueCell = gurt.create('div', { text = record.value, style = 'font-mono text-sm break-all' })
- local ttlCell = gurt.create('div', { text = record.ttl or '3600' })
+ local ttlCell = gurt.create('div', { text = record.ttl or 'none' })
local actionsCell = gurt.create('div')
local deleteBtn = gurt.create('button', {
@@ -227,16 +227,10 @@ end
local function addRecord(type, name, value, ttl)
hideError('record-error')
- print('Adding DNS record: ' .. type .. ' ' .. name .. ' ' .. value)
- print('Network request details:')
- print(' URL: gurt://localhost:8877/domain/' .. domainName .. '/records')
- print(' Method: POST')
- print(' Auth token: ' .. (authToken and 'present' or 'missing'))
- print(' Domain name: ' .. (domainName or 'nil'))
-
+
local response = fetch('gurt://localhost:8877/domain/' .. domainName .. '/records', {
method = 'POST',
- headers = {
+ headers = {
['Content-Type'] = 'application/json',
Authorization = 'Bearer ' .. authToken
},
@@ -248,38 +242,24 @@ local function addRecord(type, name, value, ttl)
})
})
- print('Response received: ' .. tostring(response))
-
if response then
- print('Response status: ' .. tostring(response.status))
- print('Response ok: ' .. tostring(response:ok()))
-
if response:ok() then
- print('DNS record added successfully')
-
- -- Clear form
gurt.select('#record-name').value = ''
gurt.select('#record-value').value = ''
- gurt.select('#record-ttl').value = '3600'
+ gurt.select('#record-ttl').value = ''
- -- Add the new record to existing records array
local newRecord = response:json()
if newRecord and newRecord.id then
- -- Server returned the created record, add it to our local array
table.insert(records, newRecord)
- -- Check if we had no records before (showing empty message)
- local wasEmpty = (#records == 1) -- If this is the first record
+ local wasEmpty = (#records == 1)
if wasEmpty then
- -- Full re-render to replace empty message with proper table
renderRecords(false)
else
- -- Just append the new record to existing table
renderRecords(true)
end
else
- -- Server didn't return record details, reload to get the actual data
loadRecords()
end
else
@@ -288,7 +268,6 @@ local function addRecord(type, name, value, ttl)
print('Failed to add DNS record: ' .. error)
end
else
- print('No response received from server')
showError('record-error', 'No response from server - connection failed')
print('Failed to add DNS record: No response')
end
@@ -310,7 +289,7 @@ local function updateHelpText()
local recordType = gurt.select('#record-type').value
-- Hide all help texts
- local helpTypes = {'A', 'AAAA', 'CNAME', 'TXT', 'NS'}
+ local helpTypes = {'A', 'AAAA', 'CNAME', 'TXT'}
for _, helpType in ipairs(helpTypes) do
local helpElement = gurt.select('#help-' .. helpType)
if helpElement then
@@ -330,7 +309,7 @@ local function updateHelpText()
valueInput.placeholder = '192.168.1.1'
elseif recordType == 'AAAA' then
valueInput.placeholder = '2001:db8::1'
- elseif recordType == 'CNAME' or recordType == 'NS' then
+ elseif recordType == 'CNAME' then
valueInput.placeholder = 'example.com'
elseif recordType == 'TXT' then
valueInput.placeholder = 'Any text content'
@@ -346,7 +325,7 @@ gurt.select('#add-record-btn'):on('click', function()
local recordType = gurt.select('#record-type').value
local recordName = gurt.select('#record-name').value
local recordValue = gurt.select('#record-value').value
- local recordTTL = tonumber(gurt.select('#record-ttl').value) or 3600
+ local recordTTL = tonumber(gurt.select('#record-ttl').value) or ''
if not recordValue or recordValue == '' then
showError('record-error', 'Record value is required')
diff --git a/dns/frontend/index.html b/dns/frontend/index.html
index 49cf136..d97961d 100644
--- a/dns/frontend/index.html
+++ b/dns/frontend/index.html
@@ -47,7 +47,7 @@
Log In
-
Don't have an account? Register here
+
Don't have an account? Register here
diff --git a/dns/frontend/signup.html b/dns/frontend/signup.html
new file mode 100644
index 0000000..e86ade0
--- /dev/null
+++ b/dns/frontend/signup.html
@@ -0,0 +1,68 @@
+
+ Sign Up
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sign Up
+
+
+
New users get 3 free domain registrations to get started!
+
+
+
+
Already have an account? Login here
+
+
+
+
diff --git a/dns/frontend/signup.lua b/dns/frontend/signup.lua
new file mode 100644
index 0000000..544c168
--- /dev/null
+++ b/dns/frontend/signup.lua
@@ -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)
diff --git a/dns/migrations/005_add_certificate_challenges.sql b/dns/migrations/005_add_certificate_challenges.sql
new file mode 100644
index 0000000..eef7d1e
--- /dev/null
+++ b/dns/migrations/005_add_certificate_challenges.sql
@@ -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);
\ No newline at end of file
diff --git a/dns/migrations/007_cleanup_invalid_records.sql b/dns/migrations/007_cleanup_invalid_records.sql
new file mode 100644
index 0000000..65c8d58
--- /dev/null
+++ b/dns/migrations/007_cleanup_invalid_records.sql
@@ -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'));
\ No newline at end of file
diff --git a/dns/migrations/008_add_ca_storage.sql b/dns/migrations/008_add_ca_storage.sql
new file mode 100644
index 0000000..266f3ef
--- /dev/null
+++ b/dns/migrations/008_add_ca_storage.sql
@@ -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
+);
\ No newline at end of file
diff --git a/dns/migrations/009_add_csr_to_challenges.sql b/dns/migrations/009_add_csr_to_challenges.sql
new file mode 100644
index 0000000..75d2996
--- /dev/null
+++ b/dns/migrations/009_add_csr_to_challenges.sql
@@ -0,0 +1,2 @@
+-- Add CSR field to certificate challenges
+ALTER TABLE certificate_challenges ADD COLUMN IF NOT EXISTS csr_pem TEXT;
\ No newline at end of file
diff --git a/dns/src/crypto.rs b/dns/src/crypto.rs
new file mode 100644
index 0000000..b409477
--- /dev/null
+++ b/dns/src/crypto.rs
@@ -0,0 +1,114 @@
+use anyhow::Result;
+use openssl::pkey::PKey;
+use openssl::rsa::Rsa;
+use openssl::x509::X509Req;
+use openssl::x509::X509Name;
+use openssl::hash::MessageDigest;
+
+pub fn generate_ca_cert() -> Result<(String, String)> {
+ let rsa = Rsa::generate(4096)?;
+ let ca_key = PKey::from_rsa(rsa)?;
+
+ let mut name_builder = X509Name::builder()?;
+ name_builder.append_entry_by_text("C", "US")?;
+ name_builder.append_entry_by_text("O", "Gurted Network")?;
+ name_builder.append_entry_by_text("CN", "Gurted Root CA")?;
+ let ca_name = name_builder.build();
+
+ let mut cert_builder = openssl::x509::X509::builder()?;
+ cert_builder.set_version(2)?;
+ cert_builder.set_subject_name(&ca_name)?;
+ cert_builder.set_issuer_name(&ca_name)?;
+ cert_builder.set_pubkey(&ca_key)?;
+
+ // validity period (10 years)
+ let not_before = openssl::asn1::Asn1Time::days_from_now(0)?;
+ let not_after = openssl::asn1::Asn1Time::days_from_now(3650)?;
+ cert_builder.set_not_before(¬_before)?;
+ cert_builder.set_not_after(¬_after)?;
+
+ let serial = openssl::bn::BigNum::from_u32(1)?.to_asn1_integer()?;
+ cert_builder.set_serial_number(&serial)?;
+
+ let context = cert_builder.x509v3_context(None, None);
+ let basic_constraints = openssl::x509::extension::BasicConstraints::new()
+ .critical()
+ .ca()
+ .build()?;
+ cert_builder.append_extension(basic_constraints)?;
+
+ let key_usage = openssl::x509::extension::KeyUsage::new()
+ .critical()
+ .key_cert_sign()
+ .crl_sign()
+ .build()?;
+ cert_builder.append_extension(key_usage)?;
+
+ cert_builder.sign(&ca_key, MessageDigest::sha256())?;
+ let ca_cert = cert_builder.build();
+
+ let ca_key_pem = ca_key.private_key_to_pem_pkcs8()?;
+ let ca_cert_pem = ca_cert.to_pem()?;
+
+ Ok((
+ String::from_utf8(ca_key_pem)?,
+ String::from_utf8(ca_cert_pem)?
+ ))
+}
+
+pub fn sign_csr_with_ca(
+ csr_pem: &str,
+ ca_cert_pem: &str,
+ ca_key_pem: &str,
+ domain: &str
+) -> Result {
+ 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(¬_before)?;
+ cert_builder.set_not_after(¬_after)?;
+
+ let mut serial = openssl::bn::BigNum::new()?;
+ serial.rand(128, openssl::bn::MsbOption::MAYBE_ZERO, false)?;
+ let asn1_serial = serial.to_asn1_integer()?;
+ cert_builder.set_serial_number(&asn1_serial)?;
+
+ let context = cert_builder.x509v3_context(Some(&ca_cert), None);
+
+ let subject_alt_name = openssl::x509::extension::SubjectAlternativeName::new()
+ .dns(domain)
+ .dns("localhost")
+ .ip("127.0.0.1")
+ .build(&context)?;
+ cert_builder.append_extension(subject_alt_name)?;
+
+ let key_usage = openssl::x509::extension::KeyUsage::new()
+ .critical()
+ .digital_signature()
+ .key_encipherment()
+ .build()?;
+ cert_builder.append_extension(key_usage)?;
+
+ let ext_key_usage = openssl::x509::extension::ExtendedKeyUsage::new()
+ .server_auth()
+ .client_auth()
+ .build()?;
+ cert_builder.append_extension(ext_key_usage)?;
+
+ cert_builder.sign(&ca_key, MessageDigest::sha256())?;
+ let cert = cert_builder.build();
+
+ let cert_pem = cert.to_pem()?;
+ Ok(String::from_utf8(cert_pem)?)
+}
diff --git a/dns/src/gurt_server.rs b/dns/src/gurt_server.rs
index 165324a..0796586 100644
--- a/dns/src/gurt_server.rs
+++ b/dns/src/gurt_server.rs
@@ -2,6 +2,7 @@ mod auth_routes;
mod helpers;
mod models;
mod routes;
+mod ca;
use crate::{auth::jwt_middleware_gurt, config::Config, discord_bot};
use colored::Colorize;
@@ -97,6 +98,10 @@ enum HandlerType {
CreateDomainRecord,
ResolveDomain,
ResolveFullDomain,
+ VerifyDomainOwnership,
+ RequestCertificate,
+ GetCertificate,
+ GetCaCertificate,
}
impl GurtHandler for AppHandler {
@@ -167,6 +172,10 @@ impl GurtHandler for AppHandler {
},
HandlerType::ResolveDomain => routes::resolve_domain(&ctx, app_state).await,
HandlerType::ResolveFullDomain => routes::resolve_full_domain(&ctx, app_state).await,
+ HandlerType::VerifyDomainOwnership => routes::verify_domain_ownership(&ctx, app_state).await,
+ HandlerType::RequestCertificate => routes::request_certificate(&ctx, app_state).await,
+ HandlerType::GetCertificate => routes::get_certificate(&ctx, app_state).await,
+ HandlerType::GetCaCertificate => routes::get_ca_certificate(&ctx, app_state).await,
};
let duration = start_time.elapsed();
@@ -237,7 +246,11 @@ pub async fn start(cli: crate::Cli) -> std::io::Result<()> {
.route(Route::put("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::UpdateDomain })
.route(Route::delete("/domain/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::DeleteDomain })
.route(Route::post("/resolve"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveDomain })
- .route(Route::post("/resolve-full"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveFullDomain });
+ .route(Route::post("/resolve-full"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::ResolveFullDomain })
+ .route(Route::get("/verify-ownership/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::VerifyDomainOwnership })
+ .route(Route::post("/ca/request-certificate"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::RequestCertificate })
+ .route(Route::get("/ca/certificate/*"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetCertificate })
+ .route(Route::get("/ca/root"), AppHandler { app_state: app_state.clone(), rate_limit_state: None, handler_type: HandlerType::GetCaCertificate });
log::info!("GURT server listening on {}", config.get_address());
server.listen(&config.get_address()).await.map_err(|e| {
diff --git a/dns/src/gurt_server/ca.rs b/dns/src/gurt_server/ca.rs
new file mode 100644
index 0000000..2ceca5b
--- /dev/null
+++ b/dns/src/gurt_server/ca.rs
@@ -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 {
+ 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> {
+ 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,
+ }))
+}
diff --git a/dns/src/gurt_server/models.rs b/dns/src/gurt_server/models.rs
index 18cd0de..022952f 100644
--- a/dns/src/gurt_server/models.rs
+++ b/dns/src/gurt_server/models.rs
@@ -82,7 +82,7 @@ pub(crate) struct ResponseDnsRecord {
pub(crate) record_type: String,
pub(crate) name: String,
pub(crate) value: String,
- pub(crate) ttl: i32,
+ pub(crate) ttl: Option,
pub(crate) priority: Option,
}
diff --git a/dns/src/gurt_server/routes.rs b/dns/src/gurt_server/routes.rs
index d5ed30d..ca52056 100644
--- a/dns/src/gurt_server/routes.rs
+++ b/dns/src/gurt_server/routes.rs
@@ -390,7 +390,7 @@ pub(crate) async fn get_domain_records(ctx: &ServerContext, app_state: AppState,
record_type: record.record_type,
name: record.name,
value: record.value,
- ttl: record.ttl.unwrap_or(3600),
+ ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -445,13 +445,13 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Record type is required"));
}
- let valid_types = ["A", "AAAA", "CNAME", "TXT", "NS"];
+ let valid_types = ["A", "AAAA", "CNAME", "TXT"];
if !valid_types.contains(&record_data.record_type.as_str()) {
- return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, TXT, and NS records are supported."));
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, and TXT records are supported."));
}
let record_name = record_data.name.unwrap_or_else(|| "@".to_string());
- let ttl = record_data.ttl.unwrap_or(3600);
+ let ttl = record_data.ttl.filter(|t| *t > 0);
match record_data.record_type.as_str() {
"A" => {
@@ -464,9 +464,9 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Invalid IPv6 address for AAAA record"));
}
},
- "CNAME" | "NS" => {
+ "CNAME" => {
if record_data.value.is_empty() || !record_data.value.contains('.') {
- return Ok(GurtResponse::bad_request().with_string_body("CNAME and NS records must contain a valid domain name"));
+ return Ok(GurtResponse::bad_request().with_string_body("CNAME records must contain a valid domain name"));
}
},
"TXT" => {
@@ -498,7 +498,7 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
record_type: record_data.record_type,
name: record_name,
value: record_data.value,
- ttl,
+ ttl: Some(ttl.unwrap_or(3600)),
priority: record_data.priority,
};
@@ -637,7 +637,7 @@ async fn try_exact_match(query_name: &str, tld: &str, app_state: &AppState) -> R
record_type: record.record_type,
name: record.name,
value: record.value,
- ttl: record.ttl.unwrap_or(3600),
+ ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -718,7 +718,7 @@ async fn try_delegation_match(query_name: &str, tld: &str, app_state: &AppState)
record_type: record.record_type,
name: record.name,
value: record.value,
- ttl: record.ttl.unwrap_or(3600),
+ ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -761,6 +761,221 @@ pub(crate) async fn resolve_full_domain(ctx: &ServerContext, app_state: AppState
}
}
+// Certificate Authority endpoints
+pub(crate) async fn verify_domain_ownership(ctx: &ServerContext, app_state: AppState) -> Result {
+ let path_parts: Vec<&str> = ctx.path().split('/').collect();
+ if path_parts.len() < 3 {
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid path format"));
+ }
+
+ let domain = path_parts[2];
+
+ let domain_parts: Vec<&str> = domain.split('.').collect();
+ if domain_parts.len() < 2 {
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
+ }
+
+ let name = domain_parts[0];
+ let tld = domain_parts[1];
+
+ let domain_record: Option = sqlx::query_as::<_, Domain>(
+ "SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
+ )
+ .bind(name)
+ .bind(tld)
+ .fetch_optional(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Database error"))?;
+
+ let exists = domain_record.is_some();
+
+ Ok(GurtResponse::ok().with_json_body(&serde_json::json!({
+ "domain": domain,
+ "exists": exists
+ }))?)
+}
+
+pub(crate) async fn request_certificate(ctx: &ServerContext, app_state: AppState) -> Result {
+ #[derive(serde::Deserialize)]
+ struct CertRequest {
+ domain: String,
+ csr: String,
+ }
+
+ let cert_request: CertRequest = serde_json::from_slice(ctx.body())
+ .map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
+
+ let domain_parts: Vec<&str> = cert_request.domain.split('.').collect();
+ if domain_parts.len() < 2 {
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
+ }
+
+ let name = domain_parts[0];
+ let tld = domain_parts[1];
+
+ let domain_record: Option = sqlx::query_as::<_, Domain>(
+ "SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
+ )
+ .bind(name)
+ .bind(tld)
+ .fetch_optional(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Database error"))?;
+
+ if domain_record.is_none() {
+ return Ok(GurtResponse::bad_request().with_string_body("Domain does not exist or is not approved"));
+ }
+
+ let token = uuid::Uuid::new_v4().to_string();
+ let verification_data = generate_challenge_data(&cert_request.domain, &token)?;
+
+ sqlx::query(
+ "INSERT INTO certificate_challenges (token, domain, challenge_type, verification_data, csr_pem, expires_at) VALUES ($1, $2, $3, $4, $5, $6)"
+ )
+ .bind(&token)
+ .bind(&cert_request.domain)
+ .bind("dns") // Only DNS challenges
+ .bind(&verification_data)
+ .bind(&cert_request.csr)
+ .bind(chrono::Utc::now() + chrono::Duration::hours(1))
+ .execute(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Failed to store challenge"))?;
+
+ let challenge = serde_json::json!({
+ "token": token,
+ "challenge_type": "dns",
+ "domain": cert_request.domain,
+ "verification_data": verification_data
+ });
+
+ Ok(GurtResponse::ok().with_json_body(&challenge)?)
+}
+
+pub(crate) async fn get_certificate(ctx: &ServerContext, app_state: AppState) -> Result {
+ let path_parts: Vec<&str> = ctx.path().split('/').collect();
+ if path_parts.len() < 4 {
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid path format"));
+ }
+
+ let token = path_parts[3];
+
+ let challenge: Option<(String, String, String, Option, chrono::DateTime)> = sqlx::query_as(
+ "SELECT domain, challenge_type, verification_data, csr_pem, expires_at FROM certificate_challenges WHERE token = $1"
+ )
+ .bind(token)
+ .fetch_optional(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Database error"))?;
+
+ let (domain, _challenge_type, verification_data, csr_pem, expires_at) = match challenge {
+ Some(c) => c,
+ None => return Ok(GurtResponse::not_found().with_string_body("Challenge not found"))
+ };
+
+ let csr_pem = match csr_pem {
+ Some(csr) => csr,
+ None => return Ok(GurtResponse::bad_request().with_string_body("CSR not found for this challenge"))
+ };
+
+ if chrono::Utc::now() > expires_at {
+ return Ok(GurtResponse::bad_request().with_string_body("Challenge expired"));
+ }
+
+ let challenge_domain = format!("_gurtca-challenge.{}", domain);
+ let domain_parts: Vec<&str> = challenge_domain.split('.').collect();
+ if domain_parts.len() < 3 {
+ return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
+ }
+
+ let record_name = "_gurtca-challenge";
+ let base_domain_name = domain_parts[domain_parts.len() - 2];
+ let tld = domain_parts[domain_parts.len() - 1];
+
+ let domain_record: Option = sqlx::query_as::<_, Domain>(
+ "SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
+ )
+ .bind(base_domain_name)
+ .bind(tld)
+ .fetch_optional(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Database error"))?;
+
+ let domain_record = match domain_record {
+ Some(d) => d,
+ None => return Ok(GurtResponse::bad_request().with_string_body("Domain not found or not approved"))
+ };
+
+ let txt_records: Vec = sqlx::query_as::<_, DnsRecord>(
+ "SELECT id, domain_id, record_type, name, value, ttl, priority, created_at FROM dns_records WHERE domain_id = $1 AND record_type = 'TXT' AND name = $2 AND value = $3"
+ )
+ .bind(domain_record.id.unwrap())
+ .bind(record_name)
+ .bind(&verification_data)
+ .fetch_all(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Database error"))?;
+
+ if txt_records.is_empty() {
+ return Ok(GurtResponse::new(gurt::GurtStatusCode::Accepted).with_string_body("Challenge not completed yet"));
+ }
+
+ let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
+ .map_err(|e| {
+ log::error!("Failed to get CA certificate: {}", e);
+ GurtError::invalid_message("CA certificate error")
+ })?;
+
+ let cert_pem = crate::crypto::sign_csr_with_ca(
+ &csr_pem,
+ &ca_cert.ca_cert_pem,
+ &ca_cert.ca_key_pem,
+ &domain
+ ).map_err(|e| {
+ log::error!("Failed to sign certificate: {}", e);
+ GurtError::invalid_message("Certificate signing failed")
+ })?;
+
+ let certificate = serde_json::json!({
+ "cert_pem": cert_pem,
+ "chain_pem": ca_cert.ca_cert_pem,
+ "expires_at": (chrono::Utc::now() + chrono::Duration::days(90)).to_rfc3339()
+ });
+
+ // Delete the challenge as it's completed
+ sqlx::query("DELETE FROM certificate_challenges WHERE token = $1")
+ .bind(token)
+ .execute(&app_state.db)
+ .await
+ .map_err(|_| GurtError::invalid_message("Failed to cleanup challenge"))?;
+
+ Ok(GurtResponse::ok().with_json_body(&certificate)?)
+}
+
+pub(crate) async fn get_ca_certificate(_ctx: &ServerContext, app_state: AppState) -> Result {
+ let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
+ .map_err(|e| {
+ log::error!("Failed to get CA certificate: {}", e);
+ GurtError::invalid_message("CA certificate error")
+ })?;
+
+ Ok(GurtResponse::ok()
+ .with_header("Content-Type", "application/x-pem-file")
+ .with_header("Content-Disposition", "attachment; filename=\"gurted-ca.crt\"")
+ .with_string_body(ca_cert.ca_cert_pem))
+}
+
+fn generate_challenge_data(domain: &str, token: &str) -> Result {
+ use sha2::{Sha256, Digest};
+
+ let data = format!("{}:{}", domain, token);
+ let mut hasher = Sha256::new();
+ hasher.update(data.as_bytes());
+ let hash = hasher.finalize();
+
+ Ok(base64::encode(hash))
+}
+
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,
diff --git a/dns/src/main.rs b/dns/src/main.rs
index b71969d..b22f33a 100644
--- a/dns/src/main.rs
+++ b/dns/src/main.rs
@@ -2,6 +2,7 @@ mod config;
mod gurt_server;
mod auth;
mod discord_bot;
+mod crypto;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{LogLevel, Verbosity};
diff --git a/flumi/.claude/settings.local.json b/flumi/.claude/settings.local.json
deleted file mode 100644
index e4c3d43..0000000
--- a/flumi/.claude/settings.local.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "permissions": {
- "allow": [
- "WebSearch",
- "WebFetch(domain:github.com)"
- ],
- "deny": [],
- "ask": []
- }
-}
\ No newline at end of file
diff --git a/flumi/Assets/gurted-ca.crt b/flumi/Assets/gurted-ca.crt
new file mode 100644
index 0000000..dd6bdb6
--- /dev/null
+++ b/flumi/Assets/gurted-ca.crt
@@ -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-----
\ No newline at end of file
diff --git a/flumi/Scripts/CertificateManager.gd b/flumi/Scripts/CertificateManager.gd
new file mode 100644
index 0000000..4787a00
--- /dev/null
+++ b/flumi/Scripts/CertificateManager.gd
@@ -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")
diff --git a/flumi/Scripts/CertificateManager.gd.uid b/flumi/Scripts/CertificateManager.gd.uid
new file mode 100644
index 0000000..48c2b0d
--- /dev/null
+++ b/flumi/Scripts/CertificateManager.gd.uid
@@ -0,0 +1 @@
+uid://bhnsb8ttn6f7n
diff --git a/flumi/Scripts/GurtProtocol.gd b/flumi/Scripts/GurtProtocol.gd
index 015dd3f..4672678 100644
--- a/flumi/Scripts/GurtProtocol.gd
+++ b/flumi/Scripts/GurtProtocol.gd
@@ -141,6 +141,9 @@ static func fetch_dns_post_working(server: String, path: String, json_data: Stri
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:
@@ -191,6 +194,9 @@ static func fetch_dns_post_working(server: String, path: String, json_data: Stri
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"}
@@ -219,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:
diff --git a/flumi/Scripts/Utils/Lua/Network.gd b/flumi/Scripts/Utils/Lua/Network.gd
index c72a95d..9fc942d 100644
--- a/flumi/Scripts/Utils/Lua/Network.gd
+++ b/flumi/Scripts/Utils/Lua/Network.gd
@@ -53,7 +53,7 @@ static func _lua_fetch_handler(vm: LuauVM) -> int:
if not has_user_agent:
headers_array.append("User-Agent: " + UserAgent.get_user_agent())
- var response_data = make_http_request(url, method, headers_array, body)
+ var response_data = await make_http_request(url, method, headers_array, body)
# Create response object with actual data
vm.lua_newtable()
@@ -127,7 +127,7 @@ static func _response_ok_handler(vm: LuauVM) -> int:
static func make_http_request(url: String, method: String, headers: PackedStringArray, body: String) -> Dictionary:
if url.begins_with("gurt://"):
- return make_gurt_request(url, method, headers, body)
+ return await make_gurt_request(url, method, headers, body)
var http_client = HTTPClient.new()
var response_data = {
"status": 0,
@@ -282,13 +282,24 @@ 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]
+
+ if _gurt_client != null:
+ _gurt_client.disconnect()
+
+ _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):
+ response_data.status = 0
+ response_data.status_text = "Connection Failed"
+ return response_data
var client = _gurt_client
diff --git a/flumi/Scripts/main.gd b/flumi/Scripts/main.gd
index 16b6f6a..118a406 100644
--- a/flumi/Scripts/main.gd
+++ b/flumi/Scripts/main.gd
@@ -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
diff --git a/flumi/addons/gdluau/bin/windows/~gdluau.windows.template_debug.x86_64.dll b/flumi/addons/gdluau/bin/windows/~gdluau.windows.template_debug.x86_64.dll
deleted file mode 100644
index 7e832a1011baaed47afe32eb5bd2c14455f15f30..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1184768
zcmeEv33!x6wswc4AwWV0B@#d+O4J~TQ53<5B#?%$r5iG`%NAuBTm~W}EV2cY0PSZh
zjvF{QGUKi@%5_i!ZV6x#2og30L
zsygS?sZ-16xpk>6(q^+o;eTD7%~k}Lzcl%K=0CLnx9G68g>7}y4=*l?aDRC5kjZyU
zPt2Y+_4m_m`$OXR+onvJnv;0@#KdX2Qxfl(l9(}QaN-}PPMFv=Hnv%kRrGVu_2}}&
zhG7f*|6hJ*==|ddzx>YN`A%_fnO`C9@c9QM-S7ps!M&va(D~Kk-ZKAtaR<+T1MZQu
z!Sg$bJ8b@bafgU|%N^qF+uRgDlxb@Q_aNMmUmD1!t4?M!9wnfq&6Wx0JF71kgzb_-N*mEu+nWH~1UKZD#`303ynE_+o3!1AKCmSs!I7|ZeL}MRfA2Z;fZ>a+0{vAT
zY}(Ye89#*YxCh~#!SHN`Hw44S7+!6KP5Ty%T3xZ%UJBQ1-+;gCS&;QJ?XOz!=LNn$
z4E~@6UnKAs!{Ada_#}a6hrtJ0@O}b!hrzpA@XG{#Z5X_T1#c|ygfRGby>$D&g6p*}
z3xjX5;H3gTg1-&gZ^54zcySo~K?}Y};7h{bQ!Mx-fxj3AA85h*2|POt-qnI%CUAEc
zyoCjCd>Z(7H(Kq7>$U%azxCU<#e$a#yfzH}ss(>u;0MFt4_fd=0)HnAKE;Ah5_mxv
ze4qvIC-Ct6T`l-!0?)?Z`uMl7;Ee^I6$bw<)oMRnuRR>T#e$a#JSi;ys}}ruf!`Pg
zf6#(25_m!we2N91B=95nTOa>{7QCOpe+h$kwcwWt{DCld3k%*@;5lLN?{2W#57%oS
z5eDC4!Ak|65e9$Nf
zw?pGMAqXF-;ZJIKzJ_N6;XO6{j~YHl!;^yWrW)Qz*I%vSN3PfX#P-9B79ZF7ztr%Z
zLHI@uuirnbgYY#PevK}_oo?T=LHKeFKcLI6(B&5d;j=aT6AfRl;S+-Jks3Zt!$)a&
zMiAap!|&Aa!5W?vgg4djmo@wm4L{O7*nW7?{@1^e@&6MYjDHZmN5e1qR^ZL)K+g%n
z*J$|HI{yxx|JfjXxrWy-zaR*|L&I;@`Mc@-LxOOZhWF9%&KiDg5Z+0{Z_x0L8r~`h
zZ=>P&Y4}|l9uVT28iac_{0iNF
z=j;B<3BqS<_&QzwE4us%LHI}wU#sEIXm~~t-c!Tt_is`V-bTX@==>Ere^d}&b*;4j
zWetBs!*?Rm-+m3RU;k=9+!yQG7-Or-N^Yk0XYc5wIKz8)Qi9>lNlF}UFx&5A$lPLdu5f$jCZ)LEf8}NQUEwmzjGwEFlH-wvxy~q2y^YANIpd&{
z=OTy7+-pa+>t2%F?Vl8<*C;xvpXXdN$l|>3rBIvtJJ%e?Y-8yx465FXQL$4m%duTQW_3lm-H&lN?iHeb2?(&^&lAeH~u7?T_CkZal+|mKjNjUi^2wX+JP7*0v-gDXHiV
z7zhZ+D$Pi`6h3DjJQL~JU+P>$*H-HMl5S$DvoYL^q_jyWB+aznjxylOLUE&XaT84Y
z7)Cnt9BattDvwuWi%M%qc26^Q&9hx*c_!#cOFvFueGe1}d<>iFm_%-TQD*kex0_c>|m#Ojl+vfj1i7
zGW!DfY_`1KYSYJdo7-HEQnQ)d+@*SKwcFmODZ{GR(IL&|=)fi-${bv4_(ttF>MD)=
zRT7BX?OTyVB6gXh;#}tR1eZA{(Pe%)X1%)A
ztEG#`V^<0rZSSLGKI%-zYrhtemb_}C;dg5IU=5E7!jD}g`P*uEq=p|vq?Lbm>UAg5iA`izgbWZZ{Cse1>Fp`jH
zog{brw&L{KOYF`;9#%8G+5S^H-d@kq5qbC)eEfyW!-ttBTpr$wX!ZAx1f|EB#B9G<
zoL*UU&Q>0#GO;gq!+OfY8xY{{l=ARfu*YW1FUe~bUjlCFa}fxtusCNV*wlXM2NZho
z@BI}HBxYiI(Kw_rd?PM5^1s|~m=%V%Z>`~N|7+KlHp+NADpIRni(%Dwpz6|mrk8pX
zA$^mp@=%%DvB*z`e)A6XgO{!Z#-PWqG(FPs+8;%vMNi8$yqSg{{-?kT{BSML=O)F8
zC@(%KqCBk;>$X3A9BDsTESsLvV@nYb0r-lSG^L*6<-o9FoK#
zNizhOIL||p#vI65og<`*e~Krv1YxNm7i-qa)L1Aq8ii
zw-68MI0O@VSw_+bTKnnAqEtfK0R#@^oHiZt&KEV@xk|v!KN0HLTIMXiW5OLXN}U^+
zrq)Vh(`l-u1WbPC6KK#h4mVQ+ZxSFTh^eu0e@1IK6EAVz4pEzoF+~F|0D7g)@o1+^
zIBR+!JEp-UaEhi*V^M|6;0Hx;;$bv0#gAae6u!trcf)>-46AekI!Lf{og~;Q2_{N{
z-8umsBzRjVsAYnQ_ecU85JDAnkl?R6K_WwCg)5Ox8dP{cd~B1i7;b6d>kJ}+f=Zi%U=;iUF3Vlj0+4#&zaenkBVQP#w^4Blvf
z%k0A>9VWI2sE2xD>yIc-Y`u5cZEwdSmEn7ry-j^A|NCUatW>L$8o^&3k^3Du{t#wA
z_2>37A(7KKAwAMTPe^1%Jt57(goMc`!205Go1eP8qoQ5jyW(8l=?TNBhp|G_e&DFZ
zyah=s5d*!agu@S|n6%JpMXb$*c@-V$PD*hMFAg&C`=l<=?NvxG)L^mmo?;LRY1`%P
zyTr7goJw*BH@m!ZR=UdizQ`{3v-g((TI}5dH3^v`+bYxkOoYysJt^I>U_iN_(H}
z#pny+6>dO=-KsjlKU%IUpoDxIK3*3i-tb;|1st@-P8VZ!6&!1g!G%Q;gNn
zJ^r!UizvhVSUtj-0}23XebOW-^8x*%2KvYDbJ0J3NBv_e@HQ|iLH(n%U;kM09)|n1
zb{y^pWNbLz&tUjP0YUwGyzjzzPp1MhZoS%*XG#9Rcu$WD8SA4*8jSTD?^&$Qv0gSz
z##+H2zXE>$Xj5W~!Uu`A1Q`OFbJCBFIQzYffpmp~fdN6~!uO-3u?omS0Tggq0BAa5
zZJ#Wc3=;qt4gf~-GD3$WWy0&y3A5pyXiImr>|36`r0-T%*O8@53<+cK?)1{W+idFF
z3;d&RCZAdb9gldZpm)RJ=+njM-vh@Q{VDM3(eDDEjDG*k{?TvG{i
zCESqlKWF{ZBjXRuPosY(yvy-#(_s8T5B0~N3Mj|^v-u&jKE`3qrEIbNz~>lqpS^;P
z*ZwRb{qwzs|ES@6HM}4QpRM6*HT)S3pAdwP)bLw0JXOOpg7BUiezS&m)9|Dqyp4t*
z_)OzR$7_!Y!jB~h{x56zBN~1Xk$(MG!|Rv7AqZci;ZN%P`8xlzemLv_?@<2*`%yrm
zzq~qq+kJvhBpt6k$DiJ`&(`qz{W&2BcWHS2{=C)?$Nt^hsBeF3tG-j)AI4mUHv6s|
z`#9LrY~P=ubJDjzL-(nzn?$*7DY@G>5~tUC_VzjG+dG)vY`=kyx0jEO=-W@j$6vU<
zE%XwuZ{Lm#s+UG-K8rEiPZg(^LFa7s?T$?Bi(S2z`t~IV@OMgmdmcDS>)QsBsO{&0
zfqb!lLdL*?G%apYSz1Cw1O|98%cbVrkJ$o0(8KIY0P)&y(Dc($)6WD%TJ$$k!|&Db
z@fw~Hg!k0&J{sOx!;^yWHX8opr$TRg>45%&@TMAmv(Dd5=Rb0Bp#Ca&k>flKKcVyQ
z48qrF_{+NdM>PCdKin6)aSiD|{*3h4o-~d1ACz^BTN8%Pi5{Qoh`~=Q7kbhEQO(oIZaw!cc8UeR>UmL9+4
z==fqEEh0T0K!Cqf(&M-1lOBDsUL>vB&j97ksrf&iD1iU}DE(c>I?k!TA43kcwNx6n
zQF6CmD^9ODbk3o_O`Ur#9d9ou9qI4k@bMSk-&ZqDcz?G=wCbf%>dqu)`^&}YWutSp
z{k@me?28@p7W?}X1o%5;f6oKMi#_}zy2(43@%;ps!thgmk~d!%Jo
z-nmr9d=x`6=B{^w=H?Bq_lD}9J>2j-$(8!CV{t4O1isiW6Vcscp{Bs%(pt)DA1zL=
zjexMbQ)oB)P5bj+rZ(GersM7P7#$h^7vSU1Wp03aXPKTC5nO-h2Lq{@bAiyEfT<5R
z2ttz1Y(JC9&0d*w&UXAeGqEqW;0=!d6$tRh@lT`u*`WP7llJHIvFW47Rn3lr8Kei~
zj$fc7>mA5il?8zCpJ`uyAxiYx@7*o*F`kasUVuo8KD-*D1Xf_6P
z7VXz5_QnT
z2ulVV;(qSWWQ{@%=FUU|W^T($?@U9N=JZ2UCOx%h;xi$2~l{zCC?}2?gnI77=`-msI)G0;0
zv6TH#Sct#um6dQz`*75~W+lcFmIlTEoqmGOUj-a>7(>~zuowX(po0WI{D`qBT!(;I
zL;$CaqO|ZC#4t5&6s3jhtx%qX8so3jDxu4)(0)XsTso$GXFm*y6opZ#Kmd>^BsA^o
z1j1qgS)(CI0x1>@-A$uD^HCT=LWXFqilWi%|+T)Dp3w
zmT08z&iw)+>;@^~DT3aQMv
zLY{Q`FD%dEVz@l>NrhOQ+gs?>8HGUT_CUHLd3sH#S%&fm`Kz`z*Yb!<+wA|s4B(5s
z4e=PXI}yS%+J0b1oNbByApTZ8VLd^+e?Kf_PJcfOsDSDJP++;MMi-~r?
z-$RUuNYqgx+>p|U9bx#!2zW9eCHFI_a|yifY40+4-`3t2;VshMB6xq*-g0mpQl6U|Qzf&jZJ0&KjBxRBmIx-C=y&Q`rc6Ar@waK5+O0qlh0-{SjqZ9x~w7
zmWM`?4tA#x1Djum+n@wk9_DO;e8u_)wtw}frX)I#w*P@9Xb<4%oWcHwtX^cT%0gC|
z|4yxc=CU^Ie=o+cnC-_&-RH9Z{Zj%a`^tPM=qv4%3JR=D2so~8u{BWh@PYS}@Xn0!<
zkJS02{BU1vQ{Y#%5)5|+`YmLWd{Xbd3;8Ps!c>3UPW>Ux1Q9
zEN6Ru;{?;2?Z2Vp?ezv7q2Dt2_zS1szc5WW{oapg^=px!^gNT8?Vk{*S2mrqrQZQe
z?2Fy`3hDP|1o%58{r0kxeqsM;i5y=0EgUGHJynm_1VsABTf^6G5%@E7y!MPB+@;~i
zHT+8rzcvVOqv4n6@|)}Oqk`~baZ>-+I{yxx{~#i*`uAvf{qi>i;TtvlW}UyA&c8Yc
ze@Vm7)9@1?3jU7<;a&~DM8liw{5e7R9UA^uX7t%joqtFW?$YpSI{zr0|5`sB^Z(17
z|06AW#`#B`{(hZ)x=w$@5om7}yy)Q@G`ypR?+n5>YWOu8-cG|;2jMSi_^le=SHm9-
z!o3>apuHA6Z7^h6O1F(glwpp)mQ5Of+}|vl6KqZFZJQ!k`76B!6)TyN1!bw={!^dt0-X8NO#5RN
z+y@uOQJ_rB|DxOW!s7W!q5DVU*SW^WwdXv9ijQ@1WPehkvjJycYOzMZtRkK@e;
zY=jhSoZV6ae?g$n5*>+%M{Q3Nx_x$YM$+$ibDJdmv(AP1M{V>w-rUqD?;YDy@Y?1@
zGc~bLhiUu91Vjq|`C`|+0Q!xKu;>^1#fu2r{9&GVP$*a3
z@@rG;bWkzOhvDt6AZihB0M1&-pKHfVAz#`a7HjZlJrn>GH_q1%`<=}1>wsXZWRo-c^2WQV0byh
zM|KAAZ}S|&2ZQ0|4DSqvXYWOLLon?64B^$maGNg?el{3h&hVqbaGQe&7X-u08O{lY
z+x!#Z3BmAkhKB^hv#Sx#2!@X_d~Gm1`x}Ilg5hHfw?dd_gFs1ZuE#{5QQMSMwZ}iI
ztH3=7U;PsL@>%WQ$bMZ$zs}e42IPy;`MgLc+%QY0?a6$1`_mR~%x%)GnDF(+!eeqU
z?h2fSyWB`EnQ?`I&FJTkp;WV+$CDC`{9+tJ+P~lM8dw=K3wKEhkV1{S4i^m7A8rp}
zI5|o?EPYtIoTSaWw+$2=n82=fn?JhEV~)bbAZ_ZzX={?jH#f;pJy4d*H>@pJ`aYwr
zn!E&k;l19C!?oU-I9XRYsjPnjvrUaocf8pzVM%}NbY9?ib8H*s<55y?6C`z+!~D=XH^1)Ot4#{hC|FU&SS&Usi-0w&D?n
zxy{)2waY9vIRzsebu!wWT)shHJEowIE;3So%}sKdCymt4a?&O(HB7O;JjVq2W$35%
z(SYQt>Ljc^chQ{k0oKrlIV{@ab4CL|lg;ZAb&*lHH)oJx&PTR=Ip?9g3bk7b
zV?q0Jzc(vwEK-FsAHKgz-_}7xBf%USO?bGvSk-~Qy=8uM1mD`_(oW-v0
zKMw$VQXGzp80fSTjJRQ*FwAy1?cG*2LaWva6QJ?;9BpdLo72XYQ%Ooq3DL(vXk!Dj
zG53qyNzsErW863pCPU&MJSL1vZiHUE}R20zp3^Z=8ea203EEeMcI(Ipg6Y;d%mk-;*=0Z1~7G~kbs;+E^mkQ(*Dt0
zv!J6=$TTprv%B2pmxftw_zAm-(X(Hiqu@BmNhV(s@hp}36Zo8cffF)DoLX2A4YJC?
zxxL}VMp-{_I9Ed)gOZ3@O%5B5D@v;44YMTwiwI+zGMbk#s`#*DM6t`9-@`F{`{8s)
z9O$J^C*D9;$Q%MUxoZgN~z;aIc>wIHxwbsgIbp19br
zR>x53ysBi{1&1A@OW>IvYZMU~{PMv3D5guX5m_SIBt)d)=wEK69-H3A05?GeL~@JK
z7>k<#$3W!l&*CfCpeZRv$G;`zp)i-(FTsfX)@6IbauDyQtPYCeE4*UnKRJm0l3JKB0Rg1fm5<^KCQv;Yt
zNccx}ET0rM2%}l!>NqpSWBwi_e3bT*EOgrspx<9ezrT=vhZ^R!KS-ZJPNX5Drr(n}
z-KD>9ZkFA3xd2GNH>H>Mqv#r)lsa^nM(KZDpGpva(gV6`4%sbHWMzs+YOtpnTH!y=i*So<9tI5vR|Bq{0<=tyS#bysm9YXXG&ZjewrlTh#Yfer2K
zC)8_6s6!FwZ|Dcs&|y~3xn7Uf*IWYm+({!;S`Lu`HCC#^4cjjB6|RZQ6AFbFEL*<{dSn#E?8HmA?uV2TWDKS_t}&(Q5ZafRa(CxBRHyA^+z>r=-_~2;cIx+u&xX!2d)}A9dFiVINtox
z@#YWeqpPs+!Sc#CgajVj?>sD}A3j2T9aEJ3DjAjZ1jFaqs_w*wp?VJKNs5f{-WzRG
z6EOTbVz-)w2;AqRS=;B??RD-~m(2m@9qs!NYA&5|JJt7sBTrrMl}VHMODODl~6Ef_i&4P=(M#+-~(M0;Y36
z!YK$VZ{K894f7Pj*$5}A7OfaCi5bE@)StOu?=y>ac$WGO3J}6uCH&YDIBGAWOu@zQ
z=I5`DKLY+PmOl&rRgl$;pA7$Mjwk$Rr+u=O
zW)}RTYf-P)xfI@#&P?K52Jf54C3Ype1F!{-*cUUFOh~#q1E^h-v1D#iH@A0U9{w-E
z|5f;3g#X2A9Fa9+lYgbf8bTCp+>N~D?18#?Z$wL5MvqHR;$B8>jC$rCoRYBDAP^Bc
zhNEGZS1(wUnqQKjnkS)tlH~zxa2vr3i_^bF{K5@9UEyE1jl$)SGxAI2hSTxOb-+A?
zUoIdPxBY-l5PsS945gUGFE>PE)a}wzj2;jhITQSHZGeGxD+3XxeOe39fW+#FQM0>)w
zMB6?ur2)TmfjUn*PXllzE$Q$JzjV@m@{8hFA)fpag97N!fxu@7L$fFHe`8j$a-F<{|v@RdZl*^>==L`Tbu*_@$X0
z{4!Bms`;f8)E;#(5V2b&Ai}iwK^cC2DWzuA(Y^ptnqS5cDE!iwzBBMkb7VauzkCg?
z1IC}g{PJTn6nIwrQjCE3t0`>UimuPfGe~VJdFRzea$uIYbz1Fvq
zeUB4w=N~#Hzd)aafpz
zYxrWec~Z+~+FhyT)0422O@f*Cqzg7RU#pK$7231OD5;7(Y`o%*+yqT2$%B=4Lg4-#
zig9@(T-e}6eC`iW4x>Y?r_lHRKR*9&^$%7U(Xy0=QANEvMTAGRdJdklzHzY2*}Bzv
z=OnC2Bx!i(B;tRvI`lbdbU@15IEe*-uTS&^u&3dj4exATvo!RcYTryKC@2go)x6#?
z*C(oF5Fys~)SMA0FiSm-0<9{n!h$!4`i}6hWUHMJgBA{NzX-VDeR~xg6+=vtB_a%yd@Es*PG8LQpMFMh5uQ}iz_RFl
zyPT=TUK6xJ1(wHR-%8+iLtwMPz6K4GRTK=@W+S)xgWJ3}&YYHPnD-`{x58d|OSCrv
zyd0&Tz8jWY-@q7k6doAG)4gL`A)KHt>mF@O&!69_Wx97pVs0`v{}Q;wO;k@YA(tnT
z?RyC^{v-IRV_dMSv$)oOpoJLbTQ{r^kUdWvKqJwFY_PlRN?=dI;(AETzdRwcYc#y`
z#T$XOkzXkI_xoVpbR`&t8zGL&!O3T{yM#@za<%5urZ&hnZ(pphNMC)EL3et0uxcUu
zKhqhqIf`!mH|ul#R(<<3>et5=dYpB`qz!h8WD~Otm~qhCVII%vMtdvvtO~5iRJ0&Y
zO}GoYQ;CwQLhZngI`<*4kGKyecp5HSSU|q}1G*Vo^%~NkegA5GNA3)%Z~R%**E?9B
zmJe=op%At2VNKh|jD;oidCatEmi0^&rR8o-EA%wj`2OAorma1@(XlwhIR67
zpP&bPgC?uZumro!{+QZ_^NIa3NAV%V;YZreOZhxy{I|%n-P#-#v$D=;E+8@IT4Rb^K5E^;h<`=M0I|^bULve#8&hSnK
z4QGRfXVtj9la>z2EJfwcBjujc}WHz`lQPw8zW^QXM?0Kk@QV9(srN2uA^TnPn4^
z8&~(Rn+HIlKrKVw~)%R4Z^<1Dw{rM#6rvH?46(P*XHo9yyBqpkjNnHNE1#i>@nP*{c=gZaC$5n5N~
zHV?b7H~y)o6Kbw5IOSF+_Bh8zC8>q!dS9m}#`s^@to+S=9lRgWj9ooCX#_5GfpF5&
zM~jI3pQo3be}C5W;@DN6UV2YDOM3bEV}C|XFOB^f|JTyX!HFUCG8pKe8@>Flx2Bh-
zkN;263*L)5CB5*eH%ni`KG-plG5;VNV(a
z9?Z!3!R3uJOxR0rMm_0qjLYRdiD9NmAm^~*y%{M&x8Xcqw5thJM#OV(4!{t_h|SRR
z4G;GmQ=9)0Z9BR?A}4ljg5X`Lp5F;t6G>k`r-*yKUnu>f^LH6;v+^ff`6r!ae#{vU
zErsAmAA2KESbj;O8h9lb!Z#>EJ+_>jQK_CfR#&IdgH1+anw+utF#JXb3h=kDbjP{0
z@4~SBi_R(kr?3>&@85pslz&B7{>Ep_Z|P^4kA3XhJ?Pt`>mqWRi88M?ZD-qg0uHCY
zg7bGG#-jRxQ>1?~EIsKb(T&A^g1+|)^;wP&xj>*L9MNJenKTt**M4vGAlm--Ug3oaWo&ShN<&)UItno9DY;
zmrS#8p6}3pFcHpo;fk>;BG{~Tp?0$$PFCTp8Zf|?VplA>KR^~3j?eIX9`@hM)hd?Z
zi@FI%EHLro90>|at<8xsydC5mSZZzV5%Lp6fXt$!VP!-wrt=uyHaE5u(_H38wdH&A
zdl066g@JUlYJNjNsr`@i-xFKU*nhJK{x|zCn`Qjl{dZBr{(Be8$jz#v$A7l}hF6}k
z|9TMoZ}wjb%lNnZZ;BYYLg;TEX1UGEy#7Dif33HivHxm6{P+8>hGqQQ{r4Vs8AAED
z7Yw$W)qw87{>%TShZ8&Zyaq?ff<5kT+p%Lm!b-9Z=|g{MgVTp%xzZyb2OVy+%5c0}
z{x;VJ5XC$8i#WU&LBrPvDn$-e*xYEN`FbX!z|3-+rFA7dSes((_|oO`z}V2Low|pc
zcD}xKF7KEg(AZO4<`@X!c08h)`njW^8s+m~Kqe2|Cd+|aInUEu*2hD}L}RHqFU8%l
z)>U#S$`d&+!PWEn+!!2@ba}@`SN##WJ?2ap9R|d~yb-6K87*e?t2}07ck23_i+NPf
z@ZNxBW3(FvODs|iFV;*3oZ{=B1hgW!7IurmQP*?fA-Nf&rmqfHRY+X$xB$y{nJqa$;2|Hgv*SV&lu5;ljo(gs>UWj6;?-a8jUlf>q47k>ri2uoW
zVE~o|Z#RADeRuAW>SokzMyS@Rt`0Vd9xh)$NSiQ8^Qk3ivh=BbQ%jg1N1T~Lmo}6z
z>BxYMx^t8;Zc3Prf&G(u^5y&CEHQWZIm?$Ge)$qOdw
zF%TBtO}cn7C^Y$9P^db7m48C{e~mufrDvqiD+&JZpwC1Wbhh-_(UPt)f}Fe2%5VFBoK$y(CqtylW#)3Wd4%^#fR;d&Cwsz!BN4HHMIE9x@i
zPNfUE1G#0tvWMOpx#kdct8(=+cq^zOK{rZ9-OhZ}9mBdC{DSrh+%>`x*^UcnD~`Vq
z&Kj=hRl9by6v*+|j&eDtTt%;-6~W$yNxi@mOElVU#0nDPm72O0>vgcd+TRK{#OIOu
zg3kkRG(KkAFnnhHN$_cTHuy9wznd<mOavVRCRN{=7M)G@e>7hmjPlY-a>)nbOzTBH^ub6sAB}>0`{}S+y_r
zAf881GyAd3*j3{pw05oIL>0S}@uSw`ChV`+H4Bm;<(nD%xviz`H2wApGA?M1KD$|0
zFdbp=N^;fh!gaBHk3mgA!TG%wz+0EQQ{yvOC(j`G5Kl}4pWA^?e~r%#g{!{cI2!
z_=t}hZs)+E=i|q)6xMcRxphBT@a=kfd=GE7@V#}mbjaHXO9`)|1R${kVf7v=&hJ$Q
zZvfx%I{5h4^Op@x=-kdw+b25KV)Y_5&$6
zt=qT${XO$0Z2LM2iiB2qG09QzH~M?d>5A7WHI(Q83AZIh<8A;KLRPzQ?S@gP%|!I
z-wcRRAA+voiBcQj5xJ<2w~Dsa4T{5-bk95D9EAfwaX7et5V3~0Yt@Yil1S62i5gtr
zW}^KmfolLffpiJqv`93}MmYE`W%^~V`ZG(R;0*|=GpC<&j_GeYMf#077ioNOh6xC%
zTe*u27C!KJLtIFh?5q0eudvd;ewOKv^KKK$zweak8_L7q3hVj8jO~Im&8OkmV;jK%
zxHD-lhe!7=2>43`R4LY<`Mq|*TbKK};cNdqd}LQlf4=y?%m6Lj*oOoVYaa*yiuZ){
zUt-Qg7V7uFZe8hC(u9nEcF7xXC}NFpGoiq@Ofz_aCjh1_sQR)M->VMs8lNrj1@L)zI&ipK<5M^q
z{>^Vo|7=4dyq$Jtd^Y0@^l8THCFB=;mcbL~pGR2gB*AAfyc(Yw@CEQ$NPIrMN%s%&
zxl!XYk~xd%KSlpQavG39xh*Id8+n_7S@Sm-STW#y1qB$EJALzK;rvQ3oK{HzIog}$
z;Bz$@`YcO#_|uDULLRa@L*|Vb3=&53QjS&4z9u|eCFS8yRm#J_?VRg-63z10R|-GQ
z&VK-)8@?$_0iu7|CT)ezYXVnhBKGgB(?Mr`^|p@
z74y;-Xa@5kZ`ksHp!%a+dcjJ%QvJRKiI(RCJ@A!A;s^lsVCbmxuq5X6KLXt(lYV&l
zUq5{6@~;V&-y~4}x3TrhZ~xon-+!9&xAUa8zRmaoO4a!L8kGO;ng;kc98a0wxYp*`G5t|3YIF2{gqTy6ab_xccbExQs&90nxf1f$AA<%mpUWEVun
z;W(pug*O0Y+3`b+_H|MHt+*uB3%*s--LfQTL3HysWR6;YO>tnIaIDB5i77CE@ARdC
zZf}6E_oHxpFFh5$tAJ1#z9&RM;4afbSDlT$1v^7lQBD0BRxlx&o1*_^yBGbolPs
z8IEr*>Iv+o|v!5Wx50=KlUYB$K#1DM@_>UkJXhzUA-A5PX*hB7v`A{w^9qHur9b
znZoV30k|$iI1mo`>ETl$c)4bqrjI?JHNgnS-**D^C``0Fnux?dESKKNRl5+ewh^UR
z2%LZxO^}lE871qVtbe(l(1(EVUQdnr309s%rU4jJekOF%U>shfJ;#|uUfbuH5ct^
zNX9ufh&jd!DMQa2jeeBK<_y!_@G9y>$N)Zvvjh0x43aObm3{w=D65UAYv;>E39Grs
z(8u+ydwxuH|6)Ch(jPAv@t4k^&|nn!U7kZq
zgM?c6K!AfFa_~qB&WCMMdkaBA=2p={D%Gz`SjCoZd)0cz<8i#ZxCfhURtMIjAMwb_
zN71N;w?RpTliN47)uHxEbB9{790hQGKu|%q3ba|>vXT%<-+`I87~`we7}k=i=Dx_~
zVKzlp4^A8XRtuk{jT;+W`DqfseM!8$gJ<6FPAb7$$UGs8raf<3O>=g@xmpijyu#g-
zPt>b*V)FhyxamvW^~1fHGIi^9(qQ<%S2qE3iF}NVp6MvTWR=T{{UanA$8RT#RuNxW@JK%fjn|>x~PXO2lwSS;sFGl?1d=ljkmk2n|7z6xw$6MpU%zy$IlKq@D!H`Qjh=gA7-tdZn8mwI=GJcGB~e>MOgT)}rkDaoyof)DPkr$__76&t3)l#olTduB?bhB*HL%Ml0rb>Q?OX
z&BqyHt58b=c9i|r-Kg7wemhF15ghazQ%Z-`xE&=uUB3Qxh>;E}h>W%sEySow15(kW
z9~zE9HEMHL8Xon4qwfok&cBjv`EyhBtv;X0QQoC8>}U^exsTHADO)liv^@pgnck+p
zLn@DN5d(n}Bk5}h!VsmyA^DGlNOYT;+zeE`l3;{MmUtbz0~tDZ75Zl32C{4VnDo)S
z`E2p?V15>jlnOT9x7cFif}}jeto>Fdjbm!~-$}T;L4Rs9)Nn#42ukXk7Q
zhOB8vjw7NSZ|+u~_9XdL7QpPrhe;ud&%4VK^s0Dq$gJ}Z;D{+FH!d&_J^UiEu8}E-
z((sFC?ZT!k8a1mQQv8)7CQ}_qAZmipWOZE|;(+V&>1$-(=jx1m*@=vt3@o+cNS$0Y
z72)8-NQ04cJlKo_?ZtQoiSd?n+dBnLy7#
z$?B^M5I`Zx9KVr4h@mzIA(iSimWABO%8p*iuZ_xa=-^QZmi*fI+#w7a>W~E6YCW9-nF!V4=|Ft6muzN>gTQKlZeJ+cr1M|
zO0Rm*iXR3{9B)dk)ley`W-y-W2(bL5HM<5Yh8lI%TtUv#)qc0Y4*9zU%+i|i(A7qn
zcpwt5gl$pRK?~6ndY#&Mep8!oEEb8>&whb=j*>-DUI{%sK#%`;oB&Uq+7yra#vev0
zhWWXAXATNVt(p6RwDCP$o2uGFwZk`bACa3EMDC9#Wy?!*^&9i08hM7V695{|sqhI?
ze}nNeKiBgS_!;w24ngQKpQp7-0K%xCH5>9kAihr7tax^xJQc>ospwL2atq0NrG(;h
zJqXpQ4iajUivvBP+O<}(#Md|UDq&)zcnabn$iq+n1XA&Nu2Nrp3v4*3^Dw&AF~}hu
zHWm7Npu_Ha%<8b^tym^dSqe{GDLW4AoBu024(!{O?8_!DAmoM#_0U`A1Em?0`8L$t0!GO#LUU{(7ZqxJ@>V$KE3DF?AswFwqdCYN@LeG4*E_Mh
zh6)LlY8`GD<t!wfjRZJMe#WgOxV+V;JY#DW5s#-}0o*))D`2!2b<+
zBgun@zh>hPnFQQL
zF39t*-5)>$ttEGGd0DmZPr(mySp}vI9?H2P3vXFlkdwh+WhC5y|Hty7;(}wx)w_?JO4C)aO
zwq*n0+y}X((kNIc15kq6F@wX;xw*xIsA4(mN8>oMY$*
zQ%)HqtNExoQ1HVbT-`&h5iLck*w6&oQgiMDfgIx`hqX9AuZ6$MMo?ifNs*|+I&{P)bDCBZY#zpmjaUrmZ*^7LvuTqt8xg*qz
zKr3AwN-BJf85ytYo&Znlu6h{e4b*}4jmo9YCaHvC2IV7S8Cae)mDG2+GbWq
z)!l|k=Mo^a_7|d?8ASIdD5HYzFT#E3)Q;cbi3Hu2T8cHaIWM#W&d(Q$|AU{QHH!0~
zAq0@df_0a=3o$^XSVrnq%>2U4c-!rD3||285485RTeEid
zy^xb~`J1;CT1!=5G{A{d;pk^p--T~VaWafkUof4C=)#STf(wyMT?KyPV%ot*6N2~8
z>*Bxo2R?E33kKC{{vFUWNikmpqu!jYqqZOlEdTSU5SD*=mPIB(mhbOHRM7Uh8=4c(
zv2dwr)sg!YZ3wkw{Ha#dEmB8L7RrAjwEUaH%RfXy50*bm%FnqBj6W1@ahVP+QsU9|
zYAZ;TIO^bMaoDOe%{1f`worNx_$hCc+PcFy1;w+v;{8cP_M6
zCT<5ciccDrt1qaWndSbQKR7n@LLt8QvK<}V4<2#l3RMm5Kn=i92|9IrgBx5{Y29I+;
z^b4wS+`3Wq60rgaufI-{&v$wKc~vIlAV;o3y$PSFm+?DC(g0DRj=ZQ5f~s@eg{YPrJjofsa2ETI|=iW+>Ge
z;y*^D)Uffe#)>t#bcRKt86LATymYDz2|B~b&5xLb!{c%PvOth*jfvkdehKoqw5FX~7wWk3vP;`87)apKZ99FloJg12ug_4GGQ
z5Y-ALB=Pm4{PlQj?YoHFw+%0ELh4f!iJWSK4JL6~GC8eD(=GKqP>V
z+nk5@{`ch;xjG-sxs%WDgSThKc+BtJoxi93Cf)3J1P`p#x*|VJ_cp;z%Uadxl4yMK
z2*{SNk_aDN9cgug)tx2WI2zES=TMzh?hKj(CI`NncIV$`ERhjjMM
z#Zq9c`jQ@wh_CMvT#U=zlulYEsXC)_Gq4M|j-k)v}!Y9muTK
z)%jyrQSf`>#rX1q@$=XC+#n9W2I=hhjGiPJ2k6XYUeA5C#K8V57WiA#PqgplmnHy1
z{Qo{87vENx8C_K_bIv^=k791yrXi`T^A>)o9G3TzUs1;gnwAy5X)$8}R^~YuS{B2O
zu$sF}yy|b23Rvzzqw}vhQ;g1^2k9HP^tus&oB#r-{Of8L#%Y1ecP;j|>ab&FiV<=v
zEZF|yEF}364HzlW^{w@Kn0-EimToqL-ZujGZ*-
zUwWeEEWW>zx4y3KU;h5e;frK2PW}Crj|KR@|NARjJW$1c$`82~7IGfrhdV>p1T^Nk
zpxx4>gy~4__=SQT`}XxA&Q7cL_M#6
zh(h#Z+DCARLIf0cRI0F}vQ;F9eT*L`C+_*@&csOyw|M}n#N7sFdtdxSIBW8d)CJ}G
zwu3kxifw9HwRONt?w$oOrQ_`|E;gd%rC9q1naGEa0z?|-NmYZRlzRRCmWsNN$fCJi
zvFuhKNU%cvfiA8X_vEAEyxC9y>P#=N|9GBO
z6L>Ap2TwII>S%u!3rV+mT_GH!WPcQ9S}fy!FRM#@O$eVhw?nQ~-@pgg@SvLHyc#(n
zPCB^oX;@gZ_^}nw)AD3|Yk}}dUkXFh-hrhS?uG5!?QkA;r`9+Md&!aX$hpRC-UzEIzr3|sEkZsFRl7+`sW!=AAzH@jE&=6Wb#@p!h@;?6mYq7I
zhod0tR73R57cxW>(H7wPzWOGPNXK7{Fzp*`#~z82A$mgnBA2?r2cCzx!}qP+_VO&|
za9ur!-H!tS9`JOnT35@GhQeQ|77@fAZ-1O=WEj=9fw&M{_^nRAnT_|osIPzq_g6j_bcVi$#6PS5!XY7y?hDX8F}gpa;J4Ig
z7y)HM^&o2EAX^9_BtM
zjrai72Gq4rMbt_dQ#%x_d*SR-e||F}=#t)16&FYYR;zXJ)J0Y|s)JUk4P{Fo
z;GUU$+G`ZpYECjX%mN2}EFGEWL-gE(N#{kh4&qOZx4OF^X^F%XB=M|Gw{LlhC>p+W
z^B9kKG$^_wzN%WWGX!(=RE|aud_@k44NludFdGGG+MM6r8F*;V)P}MC`jdx1b`7
zqa-VlfQRCEriGe$$%#h(r!LoLbgt7H^6re(6Z26h%{cx-yQ5G}!`UkSXtWLI#4dN4
zt*}CH!)~`jOuOhWf*%K1N6&??x_#$;>7Dn%EREN-(_DDb#n%d=hY9}UcO9sK}Lq-kJt
zl$cgfhyC!f#x%MCcM=g*i5_Hi^Jj7~zHZMGW7IF|cx;AEppE$5BS0J{OHv3*Q(M?5
z!qjSM9yAc1^h;1bVSWcn4yH}iX5*K6*%oPgaIu=G9=Ta(%~rD|tJUo|AEvwg0X^1W
z(vkOrNv*SCc$Ol-nnE$;PrT{DQMO*u#Sr2_6BjRM*I@OlZjPrK?ZHbx@OhR}!VOYEC~LDH~5>tVd>H+*1$
zsQHA{5o-HT`ACoV@t6Q?#Sf%5u#PwiI)mP*?6i>6?G-L_oATj7FJVI|7dATNI_y}G
z0ixk3)~mu%(2o2i=fbf)(?Q;q>Vh)ez9e~O&r^6X*X}k?R^Q}W_=_#hW}9|}3j~J`
zscv>GYKnwv%6%XuTF$>mNp9aPd`R`sewRG7$7y~Lz?_A#yo79mE{eTEehRNk$of|A
zXHIMh>di019V+Ylsgg%$04a=MuD1I8N`)$t#5y64?66S0cALVZ=;29kTPDv4rHLEx9TTjz$N&aUWP`r
zoC=I|jC>FF^zFrlOIUkn4rf~<(AFSbpetv_+;bLu^fh3oEGvZ)AI35
zF28|FnXz6?tm4xjcfEt_>&nT${E0m}5sWjc4;V+@lO&s|gEBAUq3yl!U{){dE6k%G
z630zwUXSkE1OLfGwr*~tHh2ak*lY&W>iVNgM;BGYWXZioi`{7Bb&3Y2GLOrs8Bii8o
zaM+d*IU1r7b&jo?iZuTEq&;FCJth~@ss?(SUT@}&uz%k&LF-lJM{j}g^^9I_kJe}{a30PQi2p3&VIsw
zIzJlxes9W-`JM?%_Xt%Y7YQp>U<{ax9+5nF$@3CbGbV
zr)ew5I(U(B0v>sM$FDTt#d6X48uB$iQ*Ca-mvi~dTk6&sc8~dUb#qwrTY6G!JdQp!
zyr6({0FM$=s7SE(P611PW{H=GotAhR(aaJrgXj_Qf>+zY5qv6LSOIy@LatnR3c2D~
z`~YgVWXoOmqLaNh8D4z-+&e$cllt?_nV6BGo?lpqRJ76LT*nXeQunwR5)TjGRm>Qo
zex8kdIJ%HwK;;O1+ZX$f1evvb({TiB>T1}9c;R}w}%lg+WWV|MwdAx$#4#DxNCpyk{!0u}}U?M{wuJX&P&vL&%_2|^A
z4XdDfg6h=6!_|T{Jey~Q4bMFoo*5XP4^BTk1JwxT!|-H=4^JmVUV;?vT_`jgV@s-YIG%Rit$s=hApQFVMjIzqMiLL23`
zUH>l`<$vQl0}xeOd{WD(`r;Kdg!tCeFHSu_e#A>*L6z@cm>(T8&oI-xywBf{*q6m>
zH1?ffKBjmA>)f*iNx08eFQ8+*dW9yfd$SQ)mq;P
zsG+Shf;(1;{QOszT%3Y2y@QRou-cd*YLw9#>rww|<0t8ZEown7Dc^&$mK$@@4Rh#zynn8h
zonQQ#0}60i1(XB}cohYxbmT^Rx8nb9{NE2V(JGbrGY#mTW$G<`jy_M_w1pzvos^><
z#RWh5?pF6(zATjopSGxt1-a{P{}iXYb=)mh9A-T14jtKMJYK25=MZF7K)&(z&vJWb;vM3dDfkZ~$ILYRH}D_R;mi?g{Vz~h&^4&CQVqi{
z4VLTht$>eThCQi%#KKmL@i37nyM+KF(7aLxp@vR@i_;Ssw4mV8WSBUGX4IR1)Xh~_
zqOCMxJjV~xnVx7GIon{0;Zb!`H;$^q@-`ihtIkREnA5cdP7U~!Gm4XcMLvbtBfw8-
zP~iB1H**s#D%dDiJ-&>_#U5TS@?q&?4$g98BA4~32}LUd3orzd#VDh?d_zP=b3RTP&<&5<^zbRJ`an2J+ZA7EQK
z;9_#R4@~MeIRRy!RJ@+oeo;A)q*BBOCJMLb#&+9SM3YzHc89ZMeG%6=!85|5zXDnN
z^g)&o$YAz}3}`)E0M})TOz2@IsD-2-5L_3vD}vq6_W(YG4+sM*d2iv*5NfCx?=AQx
z6x*910_P+ke0){-Z9WX-|LkTrF{p|3HU(t6YRN8@;Zhu2=U-5>K^v
z-XZmsP3Y;c`b>LJ)9Hj#B)6_q3YAM&BbQOGDQ0q4Jy{wdgbMS2ueC4d^Eq>lny2Ua{lCxi
zdNlj&&)#e8{a$OYz4jfmzpe6;D=6}5g?Y&bWd^#56e7Jnl7U?jEmKK5`IDZYGOD1h
z=q*LFJtasglx{j=yvYYaypU^{lN>?QBvJYa{H{wAL7V=ey?*mCCyPRHCBrHgfR%TGZXiCtAU6V}b>)_}NNlD)i8pQy
zOE++STV^RUp>?;MGu1Hp(gkb_8kpyj*n9YV^S@xAJ34vro~d*
zelaAnv#(q(7GzRAu@_)=%jHfX&FWBdr;#dMAIZ4ilqjU5{1V3FUB4vX8VB#$+bbt
z&d|@y$%1y=P-$(Mm~%DFZR6j$?^I2_G9GFYnVbL6&Q?4Lcmo6wS?V
zQnVGGp0?Q0)Hu)JmZ=lQQ8T1!zDohofTp6?7u+VP0C4MDlj8>zz5XY$D`$a`b
z-m1Cpu^B$rKUH6>nYfZG*BP_ziCH(aFEuQTjt{O_O2R8Ja)Pe5h)3ieM1B;`zkOk8
z&`s+&XY7mU6+Y_!Lg*ptY+9I^lL_vWWq*1T;K#a7$)A>~K9t4;oVQX~Z>
z0`_t$gh&6~sPO}Q%C2jaY`skN?x!;MhLxh(i;If-d%RWTf>kVNdm~?4$jVXgYq_1*
zdnh5kMQzCbz(W;Fi7uq(w1`TGA6lQaWFto^g{WE+DgcEzd6_80FZ?H`O4LVjEI?Yt
zLNDMUT_fJ^A$BeMSFH&fr~q=tcR75fmT@-F>&d)Igo1m}xEL~wb0XNpdk>0RQ5Fy7
zB%E4TI%0eR+k7FJ1;smbt^OHJuQcODaXjuc<7RBH?RQM%!Uy>?65zE#x*eMTG9|^!
z+bPi)i`GF#Gzd3_v7lIl?CAMcvvk+<(XU5Y(43MoI8?MYvF)kRxy*p?!?*`vc3b2C
z6So$4BYWaCazB{AX5E1Iw8CiGpo*okZfKwY_2gU?g}5xl87$WWJz%*YMzDN^|5z+%
z9pND`T%4iJMeHnh)lI%+E0w*Kr|lM!vwa69%=Bp
zkJ0xES|HEi&441<;Q)G%Drsx~tDb_u2{<@B_@{I=b2Xsvk#X5-;iSS&7zYL-HCpq~~|HblG_bG<`3!e@D@0x1<
zSu36VqiMhVw)`=EV~r@z-#MSW@vwP+PKKT^$W8859c)~{Os#TRMBmkV5%2h;XNu+j
zssCT*FWz3p9nNB-<_(*5lU35eshAC}fIfPNCE*VHz*sH0}|+!vw3a?MX+_}l^a?(4$G
z(>m_Wk=-?D~nqI+?dze1;;G(fZ;x?6UD
zZ)HppXu>@XbYhVU_H#f(KRUe;rp!;6tvDu+7+9XsC8KN!gjI2au?L>Bg^E2C0R&^|
z5TFc#dMHrJVl_xjDWo8zHdHoy%VH8o?;m$zGDJM14OAC@gJ5#clLnKW-zZFO#Um7K
zIOI~1wFA_g0&A6;Q+N<_3PWI&Wmk{!7I}Tu_(Pe{z6J?a`=D3)$Q-ZvU}}M2D`?L)
zo>~|$x4EQxhi76~H1wjpva)lh19$zU4#}Z6ZlFmla#CAIxxOhjH;
z*`cE{7j{?T8xA(op(&S--PR)PyB6Wb5^YsqStY4ZlCmHDwGQFFy~`Ci3G^Aqsm@G}
zM!$QX7*q^Fuz!rZvVi)x|Ev0c{r{%^U=zCj9#{P(snlPGqG{sCtV-MtKI1rN{n@zT
z485(t%(VE;^Trh{4aaZYzoWnKckKz#l#@4FqVWWbl>8<5Aq_dQ1<%DyTpdkE-O$iytvLIMilDrL)Q8Mha5%$5@kG
z24UamEY5LT6{;_3+09vfRACfLw@XTPDO*y;=IjA=pqb}aF;26J#!spu?
zg5vXI9fi-*p9G)n_{q@4(s)EDd{*hWXqtjJCqBo}E#r3;I;aAEe1@+Gjt>iyu8^7r
z;`4#d;>PD3_xvbOG`*%n7@tZ?#%D6~<&g{fD;TTXd5`MqE`ZO!Grnj2n%35dAA2Zo
z)z%*yJwlgSs+b2YwCvDg0n-L+O_~j|o7s+xP*=dMp;Ldgm}KVKOVv
zCT`^LR@c)nTi9@jej}1^zPTlud)D8Uq6}){QzZAs-C_p<4%flOvHUJB&JU073F(53
zmfyL}hF^EXFN*nv;VhSv-}RyRar-e(CnRE`8aw&dOH0R$Hb3)&|6?+_fNBcC4RrQW
zCd~8IYQFgI=3gTKY@mVi*PUPd!8rEj_Tu<552O8OtizV`aEnI-kyB%F=}`e)nT*k+
zFKjO2KRzNC+-;#+U5e#f%^tJl`=}`z;mIV)^)6y201i)eYKPpOqE1+Hz`{UJjN_Vna`;(A9Er%OL-Y76ysa7XS7zvSzv01s@u)xT
z>=wAf$MWF&J1|a({IR
zd~yJWAypAPLmfE4dqMuq^O?Bc+zy)VyWZNyk-Q=CuB?{%BCki7S}$S}GE8_3P{zT)O!5S5Mq
zM$dW>mD4FoU3!qyiG5Uz5-?=ygdtNW44FE4@DD?#PMG=++k;UWra3)1cV<%Jeyl)Z
z@&?Sd(6_6b~Ec`>BZ$`TZ
z^|0;Dtwi7);P~oJwpj5rdz}o$_SEnX#lA+Ew!dpJz8|nW6a`rHTJImy)dS;S5pWGu
zs{=d?R588+f($bTsUZep0`UI5W-vA!Q0sAN+gTRWIJ!pj(&N&j{1c!~$KN1hu>G=#
z&At%e?2ejYT`HU^;ktOXns-ZPS!?XZAYKR4%cmS*v_7ThRluHS
zY5X433Ovsu4rPHPb)RRsMfr&!A16iWpe3wH|VhJ5H+{5J*Ovg;U2
zl^7lg;~Yyal6=DH_%O=~{7r1SHx-kTtBT6-PA)mwcqXy!J{)7IAcJI0b%RW4f`8pzySa$E}T|A8)gU4ezdO8(-f$wbmkao37JDuJ}ps?OeyqB%t`V?=um{ec6
zl$(7ub0yhsrj&iYv{1oPI;ebPsqCXC43&GBN&{bq(urmBG#%_;!7=ih%x)h|!*|(R
zleRA@rxk9vDI9G9z0acRxG3XD}=C%)pE`04XYXSC!oMwYj`ZoA#OJzfNy7T)
zL$0wF7WU^mH2asTvKc@zc!1QS06f_#+(0kk1|itgJ781URF;MF_ER+2teE){|L>#r
z*gOx{7BRTQf;KcNkBr|S3`c%FrD6>Ow70`Z3vJq!&y~USIFqi{qO^{#$yM`qjzS0e
z
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