GURT protocol (lib, cli, gdextension, Flumi integration)

This commit is contained in:
Face
2025-08-14 20:29:19 +03:00
parent 65f3a21890
commit c117e602fe
46 changed files with 6559 additions and 89 deletions

2
.gitignore vendored
View File

@@ -0,0 +1,2 @@
*target*
*.pem

169
SPEC.md Normal file
View File

@@ -0,0 +1,169 @@
# GURT Protocol Specification
GURT is a TCP-based application protocol designed as an HTTP-like alternative with built-in TLS 1.3 encryption.
### Quick Info
- **HTTP-like syntax** with familiar methods (GET, POST, PUT, DELETE, etc.)
- **Built-in required TLS 1.3 encryption** for secure communication
- **Binary and text data support**
- **Status codes** compatible with HTTP semantics
- **Default port**: 4878
### Version
Current version: **GURT/1.0.0**
---
## Communication
- **All connections must start with a HANDSHAKE request.**
- After handshake, all further messages are sent over the encrypted TLS 1.3 connection.
### Message Types
1. **HANDSHAKE** - Establishes encrypted connection (method: `HANDSHAKE`)
2. **Standard Requests** - `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `PATCH`
3. **Responses** - Status code with optional body
---
## Message Format
### Request Format
```
METHOD /path GURT/1.0.0\r\n
header-name: header-value\r\n
content-length: 123\r\n
user-agent: GURT-Client/1.0.0\r\n
\r\n
[message body]
```
- **METHOD**: One of `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `PATCH`, `HANDSHAKE`
- **Headers**: Lowercase, separated by `:`, terminated by `\r\n`
- **Header separator**: `\r\n`
- **Body separator**: `\r\n\r\n`
- **Content-Length**: Required for all requests with a body
- **User-Agent**: Sent by default by the Rust client
### Response Format
```
GURT/1.0.0 200 OK\r\n
header-name: header-value\r\n
content-length: 123\r\n
server: GURT/1.0.0\r\n
date: Wed, 01 Jan 2020 00:00:00 GMT\r\n
\r\n
[message body]
```
- **Status line**: `GURT/1.0.0 <status_code> <status_message>`
- **Headers**: Lowercase, separated by `:`, terminated by `\r\n`
- **Header separator**: `\r\n`
- **Body separator**: `\r\n\r\n`
- **Content-Length**: Required for all responses with a body
- **Server**: Sent by default by the Rust server
- **Date**: RFC 7231 format, sent by default
### Header Notes
- All header names are **lowercased** in the protocol implementation.
- Unknown headers are ignored by default.
- Header order is not significant.
### Status Codes
- **1xx Informational**
- `101` - Switching Protocols (handshake success)
- **2xx Success**
- `200` - OK
- `201` - Created
- `202` - Accepted
- `204` - No Content
- **4xx Client Error**
- `400` - Bad Request
- `401` - Unauthorized
- `403` - Forbidden
- `404` - Not Found
- `405` - Method Not Allowed
- `408` - Timeout
- `413` - Too Large
- **5xx Server Error**
- `500` - Internal Server Error
- `501` - Not Implemented
- `502` - Bad Gateway
- `503` - Service Unavailable
- `504` - Gateway Timeout
---
## Security
### TLS 1.3 Handshake
- **All connections must use TLS 1.3**.
- **ALPN**: `"GURT/1.0"` (see `GURT_ALPN` in code)
- **Handshake**: The first message must be a `HANDSHAKE` request.
- **Server responds** with `101 Switching Protocols` and headers:
- `gurt-version: 1.0.0`
- `encryption: TLS/1.3`
- `alpn: GURT/1.0`
---
## Example Request
Below is a full example of the TCP communication for a GURT session, including handshake and a POST request/response.
```py
# Client
HANDSHAKE / GURT/1.0.0\r\n
host: example.com\r\n
user-agent: GURT-Client/1.0.0\r\n
\r\n
# Server
GURT/1.0.0 101 SWITCHING_PROTOCOLS\r\n
gurt-version: 1.0.0\r\n
encryption: TLS/1.3\r\n
alpn: gurt/1.0\r\n
server: GURT/1.0.0\r\n
date: Wed, 01 Jan 2020 00:00:00 GMT\r\n
\r\n
# Handshake is now complete; all further messages are encrypted ---
# Client
POST /api/data GURT/1.0.0\r\n
host: example.com\r\n
content-type: application/json\r\n
content-length: 17\r\n
user-agent: GURT-Client/1.0.0\r\n
\r\n
{"foo":"bar","x":1}
# Server
GURT/1.0.0 200 OK\r\n
content-type: application/json\r\n
content-length: 16\r\n
server: GURT/1.0.0\r\n
date: Wed, 01 Jan 2020 00:00:00 GMT\r\n
\r\n
{"result":"ok"}
```
## Testing
```bash
cargo test -- --nocapture
```
## Get Started
Check the `cli` folder for **Gurty**, a CLI tool to set up your GURT server.

View File

@@ -13,15 +13,37 @@ max_connections = 10
[settings]
# Available top-level domains
tld_list = [
"mf", "btw", "fr", "yap", "dev", "scam", "zip", "root",
"web", "rizz", "habibi", "sigma", "now", "it", "soy",
"lol", "uwu", "ohio", "cat"
"shit", "based", "delulu", "aura", "pmo", "sucks", "emo", "twin",
"zorp", "clank", "web", "fent", "yeah", "slop", "job", "goat",
"buss", "dawg", "opium", "gang", "ok", "wtf", "lol", "scam", "cat",
"edge", "miku", "dumb", "balls", "yap", "attic", "ayo", "dev",
# Country code TLDs
"ac", "ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as",
"at", "au", "aw", "ax", "az", "ba", "bb", "bd", "be", "bf", "bg", "bh",
"bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", "bt", "bv", "bw",
"by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm",
"cn", "co", "cr", "cu", "cv", "cw", "cx", "cy", "cz", "de", "dj", "dk",
"dm", "do", "dz", "ec", "ee", "eg", "eh", "er", "es", "et", "eu", "fi",
"fj", "fk", "fm", "fo", "fr", "ga", "gb", "gd", "ge", "gf", "gg", "gh",
"gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy",
"hk", "hm", "hn", "hr", "ht", "hu", "id", "ie", "il", "im", "in", "io",
"iq", "ir", "is", "it", "je", "jm", "jo", "jp", "ke", "kg", "kh", "ki",
"km", "kn", "kp", "kr", "kw", "ky", "kz", "la", "lb", "lc", "li", "lk",
"lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", "mg", "mh",
"mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv",
"mw", "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no",
"np", "nr", "nu", "nz", "om", "pa", "pe", "pf", "pg", "ph", "pk", "pl",
"pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", "re", "ro", "rs", "ru",
"rw", "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl",
"sm", "sn", "so", "sr", "ss", "st", "su", "sv", "sx", "sy", "sz", "tc",
"td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt",
"tv", "tw", "tz", "ua", "ug", "uk", "us", "uy", "uz", "va", "vc", "ve",
"vg", "vi", "vn", "vu", "wf", "ws", "ye", "yt", "za", "zm", "zw"
]
# Words that are not allowed in domain names
offensive_words = [
"nigg", "sex", "porn", "igg"
]
offensive_words = []
[discord]
# Discord bot token for domain approval notifications

View File

@@ -1,4 +1,3 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
@@ -8,7 +7,6 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create invite codes table
CREATE TABLE IF NOT EXISTS invite_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL,
@@ -18,7 +16,6 @@ CREATE TABLE IF NOT EXISTS invite_codes (
used_at TIMESTAMP
);
-- Create domains table
CREATE TABLE IF NOT EXISTS domains (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
@@ -31,7 +28,6 @@ CREATE TABLE IF NOT EXISTS domains (
UNIQUE(name, tld)
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_domains_name_tld ON domains(name, tld);
CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status);

View File

@@ -1,5 +1,3 @@
-- Remove email field from users table
ALTER TABLE users DROP COLUMN IF EXISTS email;
-- Drop email index if it exists
DROP INDEX IF EXISTS idx_users_email;

View File

@@ -1,4 +1,3 @@
-- Fix timestamp columns to use TIMESTAMPTZ instead of TIMESTAMP
ALTER TABLE users ALTER COLUMN created_at TYPE TIMESTAMPTZ;
ALTER TABLE invite_codes ALTER COLUMN created_at TYPE TIMESTAMPTZ;
ALTER TABLE invite_codes ALTER COLUMN used_at TYPE TIMESTAMPTZ;

View File

@@ -1,7 +1,5 @@
-- Add domain_invite_codes field to users table
ALTER TABLE users ADD COLUMN domain_invite_codes INTEGER DEFAULT 3;
-- Create domain invite codes table for domain-specific invites
CREATE TABLE IF NOT EXISTS domain_invite_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL,
@@ -11,7 +9,6 @@ CREATE TABLE IF NOT EXISTS domain_invite_codes (
used_at TIMESTAMPTZ
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_code ON domain_invite_codes(code);
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_created_by ON domain_invite_codes(created_by);
CREATE INDEX IF NOT EXISTS idx_domain_invite_codes_used_by ON domain_invite_codes(used_by);

View File

@@ -0,0 +1,23 @@
CREATE TABLE dns_records (
id SERIAL PRIMARY KEY,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
record_type VARCHAR(10) NOT NULL CHECK (record_type IN ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV')),
name VARCHAR(255) NOT NULL DEFAULT '@', -- @ for root, or subdomain name
value VARCHAR(1000) NOT NULL,
ttl INTEGER DEFAULT 3600,
priority INTEGER, -- For MX records
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_dns_records_domain_type ON dns_records(domain_id, record_type);
CREATE INDEX idx_dns_records_name ON dns_records(name);
INSERT INTO dns_records (domain_id, record_type, name, value, ttl)
SELECT id, 'A', '@', ip, 3600
FROM domains
WHERE status = 'approved';
INSERT INTO dns_records (domain_id, record_type, name, value, ttl, priority)
SELECT id, 'SRV', '_gurt._tcp', '0 5 4878 @', 3600, 0
FROM domains
WHERE status = 'approved';

View File

@@ -22,6 +22,22 @@ pub struct Domain {
pub(crate) created_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
pub struct DnsRecord {
#[serde(skip_deserializing)]
pub(crate) id: Option<i32>,
pub(crate) domain_id: i32,
#[serde(deserialize_with = "deserialize_lowercase")]
pub(crate) record_type: String, // A, AAAA, CNAME, TXT, MX, NS
#[serde(deserialize_with = "deserialize_lowercase")]
pub(crate) name: String, // subdomain or @ for root
pub(crate) value: String, // IP, domain, text value, etc.
pub(crate) ttl: Option<i32>, // Time to live in seconds
pub(crate) priority: Option<i32>, // For MX records
#[serde(skip_deserializing)]
pub(crate) created_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, FromRow)]
pub struct User {
pub(crate) id: i32,
@@ -57,6 +73,16 @@ pub(crate) struct ResponseDomain {
pub(crate) tld: String,
pub(crate) ip: String,
pub(crate) name: String,
pub(crate) records: Option<Vec<ResponseDnsRecord>>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ResponseDnsRecord {
pub(crate) record_type: String,
pub(crate) name: String,
pub(crate) value: String,
pub(crate) ttl: i32,
pub(crate) priority: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -13,14 +13,14 @@ func _resort() -> void:
size_flags_horizontal = Control.SIZE_FILL
else:
if not has_meta("size_flags_set_by_style_manager"):
size_flags_horizontal = Control.SIZE_SHRINK_CENTER
size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
# Check if we should fill vertically (for h-full)
if has_meta("should_fill_vertical"):
size_flags_vertical = Control.SIZE_FILL
else:
if not has_meta("size_flags_set_by_style_manager"):
size_flags_vertical = Control.SIZE_SHRINK_CENTER
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
if debug_draw:
_draw_rects.clear()

View File

@@ -106,7 +106,6 @@ func handle_style_element(style_element: HTMLElement) -> void:
var src = style_element.get_attribute("src")
if src.length() > 0:
# TODO: Handle external CSS loading when Network module is available
print("External CSS not yet supported: " + src)
return
# Handle inline CSS - we'll get the text content when parsing is complete
@@ -119,21 +118,19 @@ func process_styles() -> void:
if not parse_result.css_parser:
return
# Collect all style element content
var css_content = Constants.DEFAULT_CSS
var style_elements = find_all("style")
for style_element in style_elements:
if style_element.get_attribute("src").is_empty():
css_content += style_element.text_content + "\n"
print("Processing CSS: ", css_content)
# Parse CSS if we have any
if css_content.length() > 0:
parse_result.css_parser.css_text = css_content
parse_result.css_parser.parse()
for child: CSSParser.CSSRule in parse_result.css_parser.stylesheet.rules:
print("INFO: for selector \"%s\" we have props: %s" % [child.selector, child.properties])
func get_element_styles_with_inheritance(element: HTMLElement, event: String = "", visited_elements: Array = []) -> Dictionary:
if !parse_result.css_parser:
return {}
# Prevent infinite recursion
if element in visited_elements:
return {}
@@ -335,7 +332,7 @@ func process_fonts() -> void:
var weight = font_element.get_attribute("weight", "400")
if name_str and src:
FontManager.register_font(name, src, weight)
FontManager.register_font(name_str, src, weight)
func get_meta_content(name_: String) -> String:
var meta_elements = find_all("meta", "name")
@@ -366,9 +363,8 @@ func process_scripts(lua_api: LuaAPI, lua_vm) -> void:
if not src.is_empty():
# TODO: add support for external Lua script
print("External script found: ", src)
pass
elif not inline_code.is_empty():
print("Executing inline Lua script")
lua_api.execute_lua_script(inline_code, lua_vm)
func get_all_stylesheets() -> Array[String]:

View File

@@ -6,7 +6,7 @@ const SECONDARY_COLOR = Color(43/255.0, 43/255.0, 43/255.0, 1)
const HOVER_COLOR = Color(0, 0, 0, 1)
const DEFAULT_CSS = """
body { text-base text-[#000000] text-left }
body { text-base text-[#000000] text-left bg-white }
h1 { text-5xl font-bold }
h2 { text-4xl font-bold }
h3 { text-3xl font-bold }

View File

@@ -7,32 +7,51 @@ static func is_gurt_domain(url: String) -> bool:
if url.begins_with("gurt://"):
return true
var parts = url.split(".")
return parts.size() == 2 and not url.contains("://")
if not url.contains("://"):
var parts = url.split(".")
return parts.size() == 2
return false
static func parse_gurt_domain(url: String) -> Dictionary:
print("Parsing URL: ", url)
var domain_part = url
if url.begins_with("gurt://"):
domain_part = url.substr(7) # Remove "gurt://"
domain_part = url.substr(7)
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
}
var parts = domain_part.split(".")
if parts.size() != 2:
print("Invalid domain format: ", domain_part)
return {}
print("Parsed domain - name: ", parts[0], ", tld: ", parts[1])
return {
"name": parts[0],
"tld": parts[1],
"display_url": domain_part
"display_url": domain_part,
"is_direct": false
}
static func fetch_domain_info(name: String, tld: String) -> Dictionary:
print("Fetching domain info for: ", name, ".", tld)
static func is_ip_address(address: String) -> bool:
var parts = address.split(".")
if parts.size() != 4:
return false
for part in parts:
if not part.is_valid_int():
return false
var num = part.to_int()
if num < 0 or num > 255:
return false
return true
static func fetch_domain_info(name: String, tld: String) -> Dictionary:
var http_request = HTTPRequest.new()
var tree = Engine.get_main_loop()
tree.current_scene.add_child(http_request)
@@ -53,15 +72,11 @@ static func fetch_domain_info(name: String, tld: String) -> Dictionary:
http_request.queue_free()
if response[1] == 0 and response[3].size() == 0:
print("DNS API request timed out")
return {"error": "DNS server is not responding"}
var http_code = response[1]
var body = response[3]
print("DNS API response code: ", http_code)
print("DNS API response body: ", body.get_string_from_utf8())
if http_code != 200:
return {"error": "Domain not found or not approved"}
@@ -69,65 +84,124 @@ static func fetch_domain_info(name: String, tld: String) -> Dictionary:
var parse_result = json.parse(body.get_string_from_utf8())
if parse_result != OK:
print("JSON parse error: ", parse_result)
return {"error": "Invalid JSON response from DNS server"}
print("Domain info retrieved: ", json.data)
return json.data
static func fetch_index_html(ip: String) -> String:
print("Fetching index.html from IP: ", ip)
static func fetch_content_via_gurt(ip: String, path: String = "/") -> Dictionary:
var client = GurtProtocolClient.new()
var http_request = HTTPRequest.new()
var tree = Engine.get_main_loop()
tree.current_scene.add_child(http_request)
if not client.create_client(30):
return {"error": "Failed to create GURT client"}
http_request.timeout = 5.0
var gurt_url = "gurt://" + ip + ":4878" + path
var url = "http://" + ip + "/index.html"
print("Fetching from URL: ", url)
var response = client.request(gurt_url, {"method": "GET"})
var error = http_request.request(url)
client.disconnect()
if error != OK:
print("HTTP request to IP failed with error: ", error)
http_request.queue_free()
return ""
if not response:
return {"error": "No response from GURT server"}
var response = await http_request.request_completed
http_request.queue_free()
if not response.is_success:
var error_msg = "Server returned status " + str(response.status_code) + ": " + response.status_message
return {"error": error_msg}
if response[1] == 0 and response[3].size() == 0:
print("Index.html request timed out")
return ""
var http_code = response[1]
var body = response[3]
print("IP response code: ", http_code)
if http_code != 200:
print("Failed to fetch index.html, HTTP code: ", http_code)
return ""
var html_content = body.get_string_from_utf8()
print("Successfully fetched HTML content (", html_content.length(), " characters)")
return html_content
var content = response.body
return {"content": content, "headers": response.headers}
static func handle_gurt_domain(url: String) -> Dictionary:
print("Handling GURT domain: ", url)
static func fetch_content_via_gurt_direct(address: String, path: String = "/") -> Dictionary:
var shared_result = {"finished": false}
var thread = Thread.new()
var mutex = Mutex.new()
var thread_func = func():
var local_result = {}
var client = GurtProtocolClient.new()
if not client.create_client(10):
local_result = {"error": "Failed to create GURT client"}
else:
var gurt_url: String
if address.contains(":"):
gurt_url = "gurt://" + address + path
else:
gurt_url = "gurt://" + address + ":4878" + path
var response = client.request(gurt_url, {"method": "GET"})
client.disconnect()
if not response:
local_result = {"error": "No response from GURT server"}
else:
var content = response.body
if not response.is_success:
var error_msg = "Server returned status " + str(response.status_code) + ": " + response.status_message
local_result = {"error": error_msg, "content": content, "headers": response.headers}
else:
local_result = {"content": content, "headers": response.headers}
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)
var finished = false
while not finished:
await Engine.get_main_loop().process_frame
OS.delay_msec(10)
mutex.lock()
finished = shared_result.get("finished", false)
mutex.unlock()
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 handle_gurt_domain(url: String) -> Dictionary:
var parsed = parse_gurt_domain(url)
if parsed.is_empty():
return {"error": "Invalid domain format. Use: domain.tld", "html": create_error_page("Invalid domain format. Use: domain.tld")}
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 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)}
var target_address: String
var path = "/"
var html_content = await fetch_index_html(domain_info.ip)
if parsed.get("is_direct", false):
target_address = parsed.direct_address
else:
var 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
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
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
return {"error": error_msg, "html": create_error_page(error_msg)}
var html_content = content_result.content
if html_content.is_empty():
var error_msg = "Failed to fetch index.html from " + domain_info.ip
var error_msg = "Empty content received from " + target_address
return {"error": error_msg, "html": create_error_page(error_msg)}
return {"html": html_content, "display_url": parsed.display_url}
@@ -137,7 +211,7 @@ static func get_error_type(error_message: String) -> Dictionary:
return {"code": "ERR_NAME_NOT_RESOLVED", "title": "This site can't be reached", "icon": "🌐"}
elif "timeout" in error_message.to_lower() or "timed out" in error_message.to_lower():
return {"code": "ERR_CONNECTION_TIMED_OUT", "title": "This site can't be reached", "icon": ""}
elif "Failed to fetch" in error_message or "HTTP request failed" in error_message:
elif "Failed to fetch" in error_message or "No response" in error_message:
return {"code": "ERR_CONNECTION_REFUSED", "title": "This site can't be reached", "icon": "🚫"}
elif "Invalid domain format" in error_message:
return {"code": "ERR_INVALID_URL", "title": "This page isn't working", "icon": "⚠️"}

View File

@@ -45,7 +45,6 @@ func apply_button_styles(element: HTMLParser.HTMLElement, parser: HTMLParser) ->
if styles.has("font-size"):
var font_size = int(styles["font-size"])
print("SETTING FONT SIZE: ", font_size, " FOR BUTTON NAME: ", element.tag_name)
button_node.add_theme_font_size_override("font_size", font_size)
# Apply text color with state-dependent colors

View File

@@ -32,5 +32,4 @@ static func get_user_agent() -> String:
godot_version.minor,
godot_version.patch
]
print(user_agent)
return user_agent

View File

@@ -87,7 +87,7 @@ func _on_search_submitted(url: String) -> void:
tab.stop_loading()
tab.set_icon(GLOBE_ICON)
var html_bytes = result.html.to_utf8_buffer()
var html_bytes = result.html
render_content(html_bytes)
if result.has("display_url"):
@@ -105,7 +105,6 @@ func _on_search_focus_exited() -> void:
if not current_domain.is_empty():
search_bar.text = current_domain
func render() -> void:
render_content(Constants.HTML_CONTENT)

Binary file not shown.

View File

@@ -0,0 +1,13 @@
[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.1
[libraries]
macos.debug = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
macos.release = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
windows.debug.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
windows.release.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
linux.debug.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"
linux.release.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"

View File

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

View File

@@ -0,0 +1,7 @@
[plugin]
name="GURT Protocol"
description="HTTP-like networking extension for Godot games using the GURT protocol"
author="FaceDev"
version="0.1.0"
script="plugin.gd"

View File

@@ -0,0 +1,8 @@
@tool
extends EditorPlugin
func _enter_tree():
print("GURT Protocol plugin enabled")
func _exit_tree():
print("GURT Protocol plugin disabled")

View File

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

View File

@@ -29,7 +29,7 @@ window/stretch/aspect="ignore"
[editor_plugins]
enabled=PackedStringArray("res://addons/DatePicker/plugin.cfg", "res://addons/SmoothScroll/plugin.cfg", "res://addons/godot-flexbox/plugin.cfg")
enabled=PackedStringArray("res://addons/DatePicker/plugin.cfg", "res://addons/SmoothScroll/plugin.cfg", "res://addons/godot-flexbox/plugin.cfg", "res://addons/gurt-protocol/plugin.cfg")
[file_customization]

1720
protocol/cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
protocol/cli/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "gurty"
version = "0.1.0"
edition = "2021"
authors = ["FaceDev"]
license = "MIT"
repository = "https://github.com/outpoot/gurted"
description = "GURT protocol server CLI tool"
[[bin]]
name = "gurty"
path = "src/main.rs"
[dependencies]
gurt = { path = "../library" }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
clap = { version = "4.0", features = ["derive"] }
colored = "2.0"
mime_guess = "2.0"

56
protocol/cli/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Gurty - a CLI tool to setup your GURT Protocol server
## Setup for Production
For production deployments, you'll need to generate your own certificates since traditional Certificate Authorities don't support custom protocols:
1. **Generate production certificates with OpenSSL:**
```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
```
2. **Deploy with production certificates:**
```bash
cargo run --release serve --cert gurt-server.crt --key gurt-server.key --host 0.0.0.0 --port 4878
```
## Development Environment Setup
To set up a development environment for GURT, follow these steps:
1. **Install mkcert:**
```bash
# Windows (with Chocolatey)
choco install mkcert
# Or download from: https://github.com/FiloSottile/mkcert/releases
```
2. **Install local CA in system:**
```bash
mkcert -install
```
This installs a local CA in your **system certificate store**.
3. **Generate localhost certificates:**
```bash
cd gurted/protocol/cli
mkcert localhost 127.0.0.1 ::1
```
This creates:
- `localhost+2.pem` (certificate)
- `localhost+2-key.pem` (private key)
4. **Start GURT server with certificates:**
```bash
cargo run --release serve --cert localhost+2.pem --key localhost+2-key.pem
```

307
protocol/cli/src/main.rs Normal file
View File

@@ -0,0 +1,307 @@
use clap::{Parser, Subcommand};
use colored::Colorize;
use gurt::prelude::*;
use std::path::PathBuf;
use tracing::error;
use tracing_subscriber;
#[derive(Parser)]
#[command(name = "server")]
#[command(about = "GURT Protocol Server")]
#[command(version = "1.0.0")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Serve {
#[arg(short, long, default_value_t = 4878)]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(short, long, default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
verbose: bool,
#[arg(long, help = "Path to TLS certificate file")]
cert: Option<PathBuf>,
#[arg(long, help = "Path to TLS private key file")]
key: Option<PathBuf>,
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Serve { port, host, dir, verbose, cert, key } => {
if verbose {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
} else {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
}
println!("{}", "GURT Protocol Server".bright_cyan().bold());
println!("{} {}:{}", "Listening on".bright_blue(), host, port);
println!("{} {}", "Serving from".bright_blue(), dir.display());
let server = create_file_server(dir, cert, key)?;
let addr = format!("{}:{}", host, port);
if let Err(e) = server.listen(&addr).await {
error!("Server error: {}", e);
std::process::exit(1);
}
}
}
Ok(())
}
fn create_file_server(base_dir: PathBuf, cert_path: Option<PathBuf>, key_path: Option<PathBuf>) -> Result<GurtServer> {
let base_dir = std::sync::Arc::new(base_dir);
let server = match (cert_path, key_path) {
(Some(cert), Some(key)) => {
println!("TLS using certificate: {}", cert.display());
GurtServer::with_tls_certificates(
cert.to_str().ok_or_else(|| GurtError::invalid_message("Invalid certificate path"))?,
key.to_str().ok_or_else(|| GurtError::invalid_message("Invalid key path"))?
)?
}
(Some(_), None) => {
return Err(GurtError::invalid_message("Certificate provided but no key file specified (use --key)"));
}
(None, Some(_)) => {
return Err(GurtError::invalid_message("Key provided but no certificate file specified (use --cert)"));
}
(None, None) => {
return Err(GurtError::invalid_message("GURT protocol requires TLS encryption. Please provide --cert and --key parameters."));
}
};
let server = server
.get("/", {
let base_dir = base_dir.clone();
move |ctx| {
let client_ip = ctx.client_ip();
let base_dir = base_dir.clone();
async move {
// Try to serve index.html if it exists, otherwise show server info
let index_path = base_dir.join("index.html");
if index_path.exists() && index_path.is_file() {
match std::fs::read_to_string(&index_path) {
Ok(content) => {
return Ok(GurtResponse::ok()
.with_header("Content-Type", "text/html")
.with_string_body(content));
}
Err(_) => {
// Fall through to default page
}
}
}
// Default server info page
Ok(GurtResponse::ok()
.with_header("Content-Type", "text/html")
.with_string_body(format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>GURT Protocol Server</title>
<style>
body {{ font-sans m-[30px] bg-[#f5f5f5] }}
.header {{ text-[#0066cc] }}
.status {{ text-[#28a745] font-bold }}
</style>
</head>
<body>
<h1 class="header">Welcome to the GURT Protocol!</h1>
<p class="status">This server is successfully running. We couldn't find index.html though :(</p>
<p>Protocol: <strong>GURT/{}</strong></p>
<p>Client IP: <strong>{}</strong></p>
</body>
</html>
"#,
gurt::GURT_VERSION,
client_ip,
)))
}
}
})
.get("/*", {
let base_dir = base_dir.clone();
move |ctx| {
let base_dir = base_dir.clone();
let path = ctx.path().to_string();
async move {
let mut relative_path = path.strip_prefix('/').unwrap_or(&path).to_string();
// Remove any leading slashes to ensure relative path
while relative_path.starts_with('/') || relative_path.starts_with('\\') {
relative_path = relative_path[1..].to_string();
}
// If the path is now empty, use "."
let relative_path = if relative_path.is_empty() { ".".to_string() } else { relative_path };
let file_path = base_dir.join(&relative_path);
match file_path.canonicalize() {
Ok(canonical_path) => {
let canonical_base = match base_dir.canonicalize() {
Ok(base) => base,
Err(_) => {
return Ok(GurtResponse::internal_server_error()
.with_header("Content-Type", "text/plain")
.with_string_body("Server configuration error"));
}
};
if !canonical_path.starts_with(&canonical_base) {
return Ok(GurtResponse::bad_request()
.with_header("Content-Type", "text/plain")
.with_string_body("Access denied: Path outside served directory"));
}
if canonical_path.is_file() {
match std::fs::read(&canonical_path) {
Ok(content) => {
let content_type = get_content_type(&canonical_path);
Ok(GurtResponse::ok()
.with_header("Content-Type", &content_type)
.with_body(content))
}
Err(_) => {
Ok(GurtResponse::internal_server_error()
.with_header("Content-Type", "text/plain")
.with_string_body("Failed to read file"))
}
}
} else if canonical_path.is_dir() {
let index_path = canonical_path.join("index.html");
if index_path.is_file() {
match std::fs::read_to_string(&index_path) {
Ok(content) => {
Ok(GurtResponse::ok()
.with_header("Content-Type", "text/html")
.with_string_body(content))
}
Err(_) => {
Ok(GurtResponse::internal_server_error()
.with_header("Content-Type", "text/plain")
.with_string_body("Failed to read index file"))
}
}
} else {
match std::fs::read_dir(&canonical_path) {
Ok(entries) => {
let mut listing = String::from(r#"
<!DOCTYPE html>
<html>
<head>
<title>Directory Listing</title>
<style>
body { font-sans m-[40px] }
.file { my-1 }
.dir { font-bold text-[#0066cc] }
</style>
</head>
<body>
<h1>Directory Listing</h1>
<p><a href="../">← Parent Directory</a></p>
<div style="flex flex-col gap-2">
"#);
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
let is_dir = entry.path().is_dir();
let display_name = if is_dir { format!("{}/", name) } else { name.to_string() };
let class = if is_dir { "file dir" } else { "file" };
listing.push_str(&format!(
r#" <a style={} href="/{}">{}</a>"#,
class, name, display_name
));
listing.push('\n');
}
listing.push_str("</div></body>\n</html>");
Ok(GurtResponse::ok()
.with_header("Content-Type", "text/html")
.with_string_body(listing))
}
Err(_) => {
Ok(GurtResponse::internal_server_error()
.with_header("Content-Type", "text/plain")
.with_string_body("Failed to read directory"))
}
}
}
} else {
// File not found
Ok(GurtResponse::not_found()
.with_header("Content-Type", "text/html")
.with_string_body(get_404_html()))
}
}
Err(_e) => {
Ok(GurtResponse::not_found()
.with_header("Content-Type", "text/html")
.with_string_body(get_404_html()))
}
}
}
}
});
Ok(server)
}
fn get_404_html() -> &'static str {
r#"<!DOCTYPE html>
<html>
<head>
<title>404 Not Found</title>
<style>
body { font-sans m-[40px] text-center }
</style>
</head>
<body>
<h1>404 Page Not Found</h1>
<p>The requested path was not found on this GURT server.</p>
<p><a href="/">Back to home</a></p>
</body>
</html>
"#
}
fn get_content_type(path: &std::path::Path) -> String {
match path.extension().and_then(|ext| ext.to_str()) {
Some("html") | Some("htm") => "text/html".to_string(),
Some("css") => "text/css".to_string(),
Some("js") => "application/javascript".to_string(),
Some("json") => "application/json".to_string(),
Some("png") => "image/png".to_string(),
Some("jpg") | Some("jpeg") => "image/jpeg".to_string(),
Some("gif") => "image/gif".to_string(),
Some("svg") => "image/svg+xml".to_string(),
Some("ico") => "image/x-icon".to_string(),
Some("txt") => "text/plain".to_string(),
Some("xml") => "application/xml".to_string(),
Some("pdf") => "application/pdf".to_string(),
_ => "application/octet-stream".to_string(),
}
}

25
protocol/gdextension/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Rust
/target/
Cargo.lock
# Build outputs
/bin/
/addon/
# SCons
.sconf_temp/
.sconsign.dblite
config.log
# OS specific
.DS_Store
Thumbs.db
*.tmp
*.temp
# Editor specific
*.swp
*.swo
*~
.vscode/
.idea/

View File

@@ -0,0 +1,37 @@
[package]
name = "gurt-godot"
version = "0.1.0"
edition = "2021"
authors = ["FaceDev"]
license = "MIT"
repository = "https://github.com/outpoot/gurted"
description = "GURT protocol GDExtension for Godot"
[lib]
name = "gurt_godot"
crate-type = ["cdylib"]
[dependencies]
gurt = { path = "../library" }
godot = "0.1"
tokio = { version = "1.0", features = [
"net",
"io-util",
"rt",
"time"
] }
tokio-rustls = "0.26"
rustls-native-certs = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
url = "2.5"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -0,0 +1,37 @@
GURT networking extension for Godot.
## Quick Start
1. **Build the extension:**
```bash
./build.sh
```
2. **Install in your Godot project:**
- Copy `addon/gurt-protocol/` to your project's `addons/` folder (e.g. `addons/gurt-protocol`)
- Enable the plugin in `Project Settings > Plugins`
3. **Use in your game:**
```gdscript
var client = GurtProtocolClient.new()
client.create_client(30) # 30s timeout
var response = client.request("gurt://127.0.0.1:4878", {"method": "GET"})
client.disconnect() # cleanup
if response.is_success:
print(response.body) // { "content": ..., "headers": {...}, ... }
else:
print("Error: ", response.status_code, " ", response.status_message)
```
## Build Options
```bash
./build.sh # Release build for current platform
./build.sh -t debug # Debug build
./build.sh -p windows # Build for Windows
./build.sh -p linux # Build for Linux
./build.sh -p macos # Build for macOS
```

View File

@@ -0,0 +1,153 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
TARGET="release"
PLATFORM=""
while [[ $# -gt 0 ]]; do
case $1 in
-t|--target)
TARGET="$2"
shift 2
;;
-p|--platform)
PLATFORM="$2"
shift 2
;;
-h|--help)
echo "GURT Godot Extension Build Script"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -t, --target TARGET Build target (debug|release) [default: release]"
echo " -p, --platform PLATFORM Target platform (windows|linux|macos|current)"
echo " -h, --help Show this help message"
echo ""
exit 0
;;
*)
print_error "Unknown option: $1"
exit 1
;;
esac
done
if [[ "$TARGET" != "debug" && "$TARGET" != "release" ]]; then
print_error "Invalid target: $TARGET. Must be 'debug' or 'release'"
exit 1
fi
if [[ -z "$PLATFORM" ]]; then
case "$(uname -s)" in
Linux*) PLATFORM="linux";;
Darwin*) PLATFORM="macos";;
CYGWIN*|MINGW*|MSYS*) PLATFORM="windows";;
*) PLATFORM="current";;
esac
fi
print_info "GURT Godot Extension Build Script"
print_info "Target: $TARGET"
print_info "Platform: $PLATFORM"
print_info "Checking prerequisites..."
if ! command -v cargo >/dev/null 2>&1; then
print_error "Rust/Cargo not found. Please install Rust: https://rustup.rs/"
exit 1
fi
print_success "Prerequisites found"
case $PLATFORM in
windows)
RUST_TARGET="x86_64-pc-windows-msvc"
LIB_NAME="gurt_godot.dll"
;;
linux)
RUST_TARGET="x86_64-unknown-linux-gnu"
LIB_NAME="libgurt_godot.so"
;;
macos)
RUST_TARGET="x86_64-apple-darwin"
LIB_NAME="libgurt_godot.dylib"
;;
current)
RUST_TARGET=""
case "$(uname -s)" in
Linux*) LIB_NAME="libgurt_godot.so";;
Darwin*) LIB_NAME="libgurt_godot.dylib";;
CYGWIN*|MINGW*|MSYS*) LIB_NAME="gurt_godot.dll";;
*) print_error "Unsupported platform"; exit 1;;
esac
;;
*)
print_error "Unknown platform: $PLATFORM"
exit 1
;;
esac
# Create addon directory structure
ADDON_DIR="addon/gurt-protocol"
OUTPUT_DIR="$ADDON_DIR/bin/$PLATFORM"
mkdir -p "$OUTPUT_DIR"
BUILD_CMD="cargo build"
if [[ "$TARGET" == "release" ]]; then
BUILD_CMD="$BUILD_CMD --release"
fi
if [[ -n "$RUST_TARGET" ]]; then
print_info "Installing Rust target: $RUST_TARGET"
rustup target add "$RUST_TARGET"
BUILD_CMD="$BUILD_CMD --target $RUST_TARGET"
fi
print_info "Building with Cargo..."
$BUILD_CMD
if [[ -n "$RUST_TARGET" ]]; then
if [[ "$TARGET" == "release" ]]; then
BUILT_LIB="target/$RUST_TARGET/release/$LIB_NAME"
else
BUILT_LIB="target/$RUST_TARGET/debug/$LIB_NAME"
fi
else
if [[ "$TARGET" == "release" ]]; then
BUILT_LIB="target/release/$LIB_NAME"
else
BUILT_LIB="target/debug/$LIB_NAME"
fi
fi
if [[ -f "$BUILT_LIB" ]]; then
cp "$BUILT_LIB" "$OUTPUT_DIR/$LIB_NAME"
# Copy addon files
cp gurt_godot.gdextension "$ADDON_DIR/"
cp plugin.cfg "$ADDON_DIR/"
cp plugin.gd "$ADDON_DIR/"
print_success "Build completed: $OUTPUT_DIR/$LIB_NAME"
SIZE=$(du -h "$OUTPUT_DIR/$LIB_NAME" | cut -f1)
print_info "Library size: $SIZE"
else
print_error "Built library not found at: $BUILT_LIB"
exit 1
fi
print_success "Build process completed!"
print_info "Copy the 'addon/gurt-protocol' folder to your project's 'addons/' directory"

View File

@@ -0,0 +1,13 @@
[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.1
[libraries]
macos.debug = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
macos.release = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
windows.debug.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
windows.release.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
linux.debug.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"
linux.release.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"

View File

@@ -0,0 +1,7 @@
[plugin]
name="GURT Protocol"
description="HTTP-like networking extension for Godot games using the GURT protocol"
author="FaceDev"
version="0.1.0"
script="plugin.gd"

View File

@@ -0,0 +1,8 @@
@tool
extends EditorPlugin
func _enter_tree():
print("GURT Protocol plugin enabled")
func _exit_tree():
print("GURT Protocol plugin disabled")

View File

@@ -0,0 +1,375 @@
use godot::prelude::*;
use gurt::prelude::*;
use gurt::{GurtMethod, GurtRequest};
use tokio::runtime::Runtime;
use std::sync::Arc;
use std::cell::RefCell;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
struct GurtGodotExtension;
#[gdextension]
unsafe impl ExtensionLibrary for GurtGodotExtension {}
#[derive(GodotClass)]
#[class(init)]
struct GurtProtocolClient {
base: Base<RefCounted>,
client: Arc<RefCell<Option<GurtClient>>>,
runtime: Arc<RefCell<Option<Runtime>>>,
}
#[derive(GodotClass)]
#[class(init)]
struct GurtGDResponse {
base: Base<RefCounted>,
#[var]
status_code: i32,
#[var]
status_message: GString,
#[var]
headers: Dictionary,
#[var]
is_success: bool,
#[var]
body: PackedByteArray, // Raw bytes
#[var]
text: GString, // Decoded text
}
#[godot_api]
impl GurtGDResponse {
#[func]
fn get_header(&self, key: GString) -> GString {
self.headers.get(key).map_or(GString::new(), |v| v.to::<GString>())
}
#[func]
fn is_binary(&self) -> bool {
let content_type = self.get_header("content-type".into()).to_string();
content_type.starts_with("image/") ||
content_type.starts_with("application/octet-stream") ||
content_type.starts_with("video/") ||
content_type.starts_with("audio/")
}
#[func]
fn is_text(&self) -> bool {
let content_type = self.get_header("content-type".into()).to_string();
content_type.starts_with("text/") ||
content_type.starts_with("application/json") ||
content_type.starts_with("application/xml") ||
content_type.is_empty()
}
#[func]
fn debug_info(&self) -> GString {
let content_length = self.get_header("content-length".into()).to_string();
let actual_size = self.body.len();
let content_type = self.get_header("content-type".into()).to_string();
let size_match = content_length.parse::<usize>().unwrap_or(0) == actual_size;
format!(
"Status: {} | Type: {} | Length: {} | Actual: {} | Match: {}",
self.status_code,
content_type,
content_length,
actual_size,
size_match
).into()
}
}
#[derive(GodotClass)]
#[class(init)]
struct GurtProtocolServer {
base: Base<RefCounted>,
}
#[godot_api]
impl GurtProtocolClient {
#[signal]
fn request_completed(response: Gd<GurtGDResponse>);
#[func]
fn create_client(&mut self, timeout_seconds: i32) -> bool {
let runtime = match Runtime::new() {
Ok(rt) => rt,
Err(e) => {
godot_print!("Failed to create runtime: {}", e);
return false;
}
};
let mut config = ClientConfig::default();
config.request_timeout = tokio::time::Duration::from_secs(timeout_seconds as u64);
let client = GurtClient::with_config(config);
*self.runtime.borrow_mut() = Some(runtime);
*self.client.borrow_mut() = Some(client);
true
}
#[func]
fn request(&self, url: GString, options: Dictionary) -> Option<Gd<GurtGDResponse>> {
let runtime_binding = self.runtime.borrow();
let runtime = match runtime_binding.as_ref() {
Some(rt) => rt,
None => {
godot_print!("No runtime available");
return None;
}
};
let url_str = url.to_string();
// Parse URL to get host and port
let parsed_url = match url::Url::parse(&url_str) {
Ok(u) => u,
Err(e) => {
godot_print!("Invalid URL: {}", e);
return None;
}
};
let host = match parsed_url.host_str() {
Some(h) => h,
None => {
godot_print!("URL must have a host");
return None;
}
};
let port = parsed_url.port().unwrap_or(4878);
let path = if parsed_url.path().is_empty() { "/" } else { parsed_url.path() };
let method_str = options.get("method").unwrap_or("GET".to_variant()).to::<String>();
let method = match method_str.to_uppercase().as_str() {
"GET" => GurtMethod::GET,
"POST" => GurtMethod::POST,
"PUT" => GurtMethod::PUT,
"DELETE" => GurtMethod::DELETE,
"PATCH" => GurtMethod::PATCH,
"HEAD" => GurtMethod::HEAD,
"OPTIONS" => GurtMethod::OPTIONS,
_ => {
godot_print!("Unsupported HTTP method: {}", method_str);
GurtMethod::GET
}
};
let response = match runtime.block_on(self.gurt_request_with_handshake(host, port, method, path)) {
Ok(resp) => resp,
Err(e) => {
godot_print!("GURT request failed: {}", e);
return None;
}
};
Some(self.convert_response(response))
}
async fn gurt_request_with_handshake(&self, host: &str, port: u16, method: GurtMethod, path: &str) -> gurt::Result<GurtResponse> {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr).await?;
let handshake_request = GurtRequest::new(GurtMethod::HANDSHAKE, "/".to_string())
.with_header("Host", host)
.with_header("User-Agent", &format!("GURT-Client/{}", gurt::GURT_VERSION));
let handshake_data = handshake_request.to_string();
stream.write_all(handshake_data.as_bytes()).await?;
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 {
break;
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
let separator = b"\r\n\r\n";
if buffer.windows(separator.len()).any(|w| w == separator) {
break;
}
}
let handshake_response = GurtResponse::parse_bytes(&buffer)?;
if handshake_response.status_code != 101 {
return Err(GurtError::handshake(format!("Handshake failed: {} {}",
handshake_response.status_code,
handshake_response.status_message)));
}
let tls_stream = self.create_secure_tls_connection(stream, host).await?;
let (mut reader, mut writer) = tokio::io::split(tls_stream);
let actual_request = GurtRequest::new(method, path.to_string())
.with_header("Host", host)
.with_header("User-Agent", &format!("GURT-Client/{}", gurt::GURT_VERSION))
.with_header("Accept", "*/*");
let request_data = actual_request.to_string();
writer.write_all(request_data.as_bytes()).await?;
let mut response_buffer = Vec::new();
let mut temp_buf = [0u8; 8192];
let mut headers_complete = false;
while !headers_complete {
let bytes_read = reader.read(&mut temp_buf).await?;
if bytes_read == 0 {
break;
}
response_buffer.extend_from_slice(&temp_buf[..bytes_read]);
let separator = b"\r\n\r\n";
if response_buffer.windows(separator.len()).any(|w| w == separator) {
headers_complete = true;
}
}
let response = GurtResponse::parse_bytes(&response_buffer)?;
let content_length = response.header("content-length")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let separator_pos = response_buffer.windows(4).position(|w| w == b"\r\n\r\n").unwrap_or(0) + 4;
let current_body_len = response_buffer.len().saturating_sub(separator_pos);
if content_length > current_body_len {
let remaining = content_length - current_body_len;
let mut remaining_buffer = vec![0u8; remaining];
match reader.read_exact(&mut remaining_buffer).await {
Ok(_) => {
response_buffer.extend_from_slice(&remaining_buffer);
}
Err(e) => {
godot_error!("Failed to read remaining {} bytes: {}", remaining, e);
// Don't fail completely, try to parse what we have
}
}
}
drop(reader);
drop(writer);
let final_response = GurtResponse::parse_bytes(&response_buffer)?;
Ok(final_response)
}
async fn create_secure_tls_connection(&self, stream: tokio::net::TcpStream, host: &str) -> gurt::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
use tokio_rustls::rustls::{ClientConfig, RootCertStore};
use std::sync::Arc;
let mut root_store = RootCertStore::empty();
let cert_result = rustls_native_certs::load_native_certs();
let mut system_cert_count = 0;
for cert in cert_result.certs {
if root_store.add(cert).is_ok() {
system_cert_count += 1;
}
}
if system_cert_count <= 0 {
godot_error!("No system certificates found. TLS connections will fail.");
}
let mut client_config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
client_config.alpn_protocols = vec![gurt::crypto::GURT_ALPN.to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = match host {
"127.0.0.1" => "localhost",
"localhost" => "localhost",
_ => host
};
let domain = tokio_rustls::rustls::pki_types::ServerName::try_from(server_name.to_string())
.map_err(|e| GurtError::connection(format!("Invalid server name '{}': {}", server_name, e)))?;
match connector.connect(domain, stream).await {
Ok(tls_stream) => {
Ok(tls_stream)
}
Err(e) => {
godot_error!("TLS handshake failed: {}", e);
Err(GurtError::connection(format!("TLS handshake failed: {}", e)))
}
}
}
#[func]
fn disconnect(&mut self) {
*self.client.borrow_mut() = None;
*self.runtime.borrow_mut() = None;
}
#[func]
fn is_connected(&self) -> bool {
self.client.borrow().is_some()
}
#[func]
fn get_version(&self) -> GString {
gurt::GURT_VERSION.to_string().into()
}
#[func]
fn get_default_port(&self) -> i32 {
gurt::DEFAULT_PORT as i32
}
fn convert_response(&self, response: GurtResponse) -> Gd<GurtGDResponse> {
let mut gd_response = GurtGDResponse::new_gd();
gd_response.bind_mut().status_code = response.status_code as i32;
gd_response.bind_mut().status_message = response.status_message.clone().into();
gd_response.bind_mut().is_success = response.is_success();
let mut headers = Dictionary::new();
for (key, value) in &response.headers {
headers.set(key.clone(), value.clone());
}
gd_response.bind_mut().headers = headers;
let mut body = PackedByteArray::new();
body.resize(response.body.len());
for (i, byte) in response.body.iter().enumerate() {
body[i] = *byte;
}
gd_response.bind_mut().body = body;
match std::str::from_utf8(&response.body) {
Ok(text_str) => {
gd_response.bind_mut().text = text_str.into();
}
Err(_) => {
let content_type = response.headers.get("content-type").cloned().unwrap_or_default();
let size = response.body.len();
gd_response.bind_mut().text = format!("[Binary data: {} ({} bytes)]", content_type, size).into();
}
}
gd_response
}
}

1540
protocol/library/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
[package]
name = "gurt"
version = "0.1.0"
edition = "2021"
authors = ["FaceDev"]
license = "MIT"
repository = "https://github.com/outpoot/gurted"
description = "Official GURT:// protocol implementation"
[lib]
name = "gurt"
crate-type = ["cdylib", "lib"]
[dependencies]
tokio = { version = "1.0", features = [
"net",
"io-util",
"rt",
"macros",
"rt-multi-thread",
"time",
"fs"
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] }
tokio-rustls = "0.26"
rustls = "0.23"
rustls-pemfile = "2.0"
base64 = "0.22"
url = "2.5"
[dev-dependencies]
tokio-test = "0.4"
tracing-subscriber = "0.3"
sha2 = "0.10"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -0,0 +1,3 @@
The Rust implementation of the Gurt protocol, used internally by the **Gurty CLI** and the **Gurt GDExtension** (which is used by Flumi, the official browser).
See the `examples/` directory for usage examples. For protocol details and design, refer to `SPEC.md` or the source code.

View File

@@ -0,0 +1,18 @@
use gurt::{GurtServer, GurtResponse, ServerContext, Result};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let server = GurtServer::with_tls_certificates("cert.pem", "cert.key.pem")?
.get("/", |_ctx: &ServerContext| async {
Ok(GurtResponse::ok().with_string_body("<h1>Hello from GURT!</h1>"))
})
.get("/test", |_ctx: &ServerContext| async {
Ok(GurtResponse::ok().with_string_body("Test endpoint working!"))
});
println!("Starting GURT server on gurt://127.0.0.1:4878");
server.listen("127.0.0.1:4878").await
}

View File

@@ -0,0 +1,302 @@
use crate::{
GurtError, Result, GurtRequest, GurtResponse,
protocol::{DEFAULT_PORT, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, DEFAULT_HANDSHAKE_TIMEOUT, BODY_SEPARATOR},
message::GurtMethod,
};
use tokio::net::TcpStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{timeout, Duration};
use url::Url;
use tracing::debug;
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub handshake_timeout: Duration,
pub user_agent: String,
pub max_redirects: usize,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(DEFAULT_CONNECTION_TIMEOUT),
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT),
handshake_timeout: Duration::from_secs(DEFAULT_HANDSHAKE_TIMEOUT),
user_agent: format!("GURT-Client/{}", crate::GURT_VERSION),
max_redirects: 5,
}
}
}
#[derive(Debug)]
struct PooledConnection {
stream: TcpStream,
}
impl PooledConnection {
fn new(stream: TcpStream) -> Self {
Self { stream }
}
}
pub struct GurtClient {
config: ClientConfig,
}
impl GurtClient {
pub fn new() -> Self {
Self {
config: ClientConfig::default(),
}
}
pub fn with_config(config: ClientConfig) -> Self {
Self {
config,
}
}
async fn create_connection(&self, host: &str, port: u16) -> Result<PooledConnection> {
let addr = format!("{}:{}", host, port);
let stream = timeout(
self.config.connect_timeout,
TcpStream::connect(&addr)
).await
.map_err(|_| GurtError::timeout("Connection timeout"))?
.map_err(|e| GurtError::connection(format!("Failed to connect: {}", e)))?;
let conn = PooledConnection::new(stream);
Ok(conn)
}
async fn read_response_data(&self, stream: &mut TcpStream) -> Result<Vec<u8>> {
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
let start_time = std::time::Instant::now();
loop {
if start_time.elapsed() > self.config.request_timeout {
return Err(GurtError::timeout("Response timeout"));
}
let bytes_read = stream.read(&mut temp_buffer).await?;
if bytes_read == 0 {
break; // Connection closed
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
// Check for complete message without converting to string
let body_separator = BODY_SEPARATOR.as_bytes();
let has_complete_response = buffer.windows(body_separator.len()).any(|w| w == body_separator) ||
(buffer.starts_with(b"{") && buffer.ends_with(b"}"));
if has_complete_response {
return Ok(buffer);
}
}
if buffer.is_empty() {
Err(GurtError::connection("Connection closed unexpectedly"))
} else {
Ok(buffer)
}
}
async fn send_request_internal(&self, host: &str, port: u16, request: GurtRequest) -> Result<GurtResponse> {
debug!("Sending {} {} to {}:{}", request.method, request.path, host, port);
let mut conn = self.create_connection(host, port).await?;
let request_data = request.to_string();
conn.stream.write_all(request_data.as_bytes()).await?;
let response_bytes = timeout(
self.config.request_timeout,
self.read_response_data(&mut conn.stream)
).await
.map_err(|_| GurtError::timeout("Request timeout"))??;
let response = GurtResponse::parse_bytes(&response_bytes)?;
Ok(response)
}
pub async fn get(&self, url: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::GET, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Accept", "*/*");
self.send_request_internal(&host, port, request).await
}
pub async fn post(&self, url: &str, body: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::POST, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "text/plain")
.with_string_body(body);
self.send_request_internal(&host, port, request).await
}
/// POST request with JSON body
pub async fn post_json<T: serde::Serialize>(&self, url: &str, data: &T) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let json_body = serde_json::to_string(data)?;
let request = GurtRequest::new(GurtMethod::POST, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "application/json")
.with_string_body(json_body);
self.send_request_internal(&host, port, request).await
}
/// PUT request with body
pub async fn put(&self, url: &str, body: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::PUT, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "text/plain")
.with_string_body(body);
self.send_request_internal(&host, port, request).await
}
/// PUT request with JSON body
pub async fn put_json<T: serde::Serialize>(&self, url: &str, data: &T) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let json_body = serde_json::to_string(data)?;
let request = GurtRequest::new(GurtMethod::PUT, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "application/json")
.with_string_body(json_body);
self.send_request_internal(&host, port, request).await
}
pub async fn delete(&self, url: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::DELETE, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent);
self.send_request_internal(&host, port, request).await
}
pub async fn head(&self, url: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::HEAD, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent);
self.send_request_internal(&host, port, request).await
}
pub async fn options(&self, url: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::OPTIONS, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent);
self.send_request_internal(&host, port, request).await
}
/// PATCH request with body
pub async fn patch(&self, url: &str, body: &str) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let request = GurtRequest::new(GurtMethod::PATCH, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "text/plain")
.with_string_body(body);
self.send_request_internal(&host, port, request).await
}
/// PATCH request with JSON body
pub async fn patch_json<T: serde::Serialize>(&self, url: &str, data: &T) -> Result<GurtResponse> {
let (host, port, path) = self.parse_url(url)?;
let json_body = serde_json::to_string(data)?;
let request = GurtRequest::new(GurtMethod::PATCH, path)
.with_header("Host", &host)
.with_header("User-Agent", &self.config.user_agent)
.with_header("Content-Type", "application/json")
.with_string_body(json_body);
self.send_request_internal(&host, port, request).await
}
pub async fn send_request(&self, host: &str, port: u16, request: GurtRequest) -> Result<GurtResponse> {
self.send_request_internal(host, port, request).await
}
fn parse_url(&self, url: &str) -> Result<(String, u16, String)> {
let parsed_url = Url::parse(url).map_err(|e| GurtError::invalid_message(format!("Invalid URL: {}", e)))?;
if parsed_url.scheme() != "gurt" {
return Err(GurtError::invalid_message("URL must use gurt:// scheme"));
}
let host = parsed_url.host_str()
.ok_or_else(|| GurtError::invalid_message("URL must have a host"))?
.to_string();
let port = parsed_url.port().unwrap_or(DEFAULT_PORT);
let path = if parsed_url.path().is_empty() {
"/".to_string()
} else {
parsed_url.path().to_string()
};
Ok((host, port, path))
}
}
impl Default for GurtClient {
fn default() -> Self {
Self::new()
}
}
impl Clone for GurtClient {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_url_parsing() {
let client = GurtClient::new();
let (host, port, path) = client.parse_url("gurt://example.com/test").unwrap();
assert_eq!(host, "example.com");
assert_eq!(port, DEFAULT_PORT);
assert_eq!(path, "/test");
let (host, port, path) = client.parse_url("gurt://example.com:8080/api/v1").unwrap();
assert_eq!(host, "example.com");
assert_eq!(port, 8080);
assert_eq!(path, "/api/v1");
}
}

View File

@@ -0,0 +1,123 @@
use crate::{GurtError, Result};
use rustls::{ClientConfig, ServerConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use tokio_rustls::{TlsConnector, TlsAcceptor};
use std::sync::Arc;
pub const TLS_VERSION: &str = "TLS/1.3";
pub const GURT_ALPN: &[u8] = b"GURT/1.0";
#[derive(Debug, Clone)]
pub struct TlsConfig {
pub client_config: Option<Arc<ClientConfig>>,
pub server_config: Option<Arc<ServerConfig>>,
}
impl TlsConfig {
pub fn new_client() -> Result<Self> {
let mut config = ClientConfig::builder()
.with_root_certificates(rustls::RootCertStore::empty())
.with_no_client_auth();
config.alpn_protocols = vec![GURT_ALPN.to_vec()];
Ok(Self {
client_config: Some(Arc::new(config)),
server_config: None,
})
}
pub fn new_server(cert_chain: Vec<CertificateDer<'static>>, private_key: PrivateKeyDer<'static>) -> Result<Self> {
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, private_key)
.map_err(|e| GurtError::crypto(format!("TLS server config error: {}", e)))?;
config.alpn_protocols = vec![GURT_ALPN.to_vec()];
Ok(Self {
client_config: None,
server_config: Some(Arc::new(config)),
})
}
pub fn get_connector(&self) -> Result<TlsConnector> {
let config = self.client_config.as_ref()
.ok_or_else(|| GurtError::crypto("No client config available"))?;
Ok(TlsConnector::from(config.clone()))
}
pub fn get_acceptor(&self) -> Result<TlsAcceptor> {
let config = self.server_config.as_ref()
.ok_or_else(|| GurtError::crypto("No server config available"))?;
Ok(TlsAcceptor::from(config.clone()))
}
}
#[derive(Debug)]
pub struct CryptoManager {
tls_config: Option<TlsConfig>,
}
impl CryptoManager {
pub fn new() -> Self {
Self {
tls_config: None,
}
}
pub fn with_tls_config(config: TlsConfig) -> Self {
Self {
tls_config: Some(config),
}
}
pub fn set_tls_config(&mut self, config: TlsConfig) {
self.tls_config = Some(config);
}
pub fn has_tls_config(&self) -> bool {
self.tls_config.is_some()
}
pub fn get_tls_connector(&self) -> Result<TlsConnector> {
let config = self.tls_config.as_ref()
.ok_or_else(|| GurtError::crypto("No TLS config available"))?;
config.get_connector()
}
pub fn get_tls_acceptor(&self) -> Result<TlsAcceptor> {
let config = self.tls_config.as_ref()
.ok_or_else(|| GurtError::crypto("No TLS config available"))?;
config.get_acceptor()
}
}
impl Default for CryptoManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tls_config_creation() {
let client_config = TlsConfig::new_client();
assert!(client_config.is_ok());
let config = client_config.unwrap();
assert!(config.client_config.is_some());
assert!(config.server_config.is_none());
}
#[test]
fn test_crypto_manager() {
let crypto = CryptoManager::new();
assert!(!crypto.has_tls_config());
}
}

View File

@@ -0,0 +1,71 @@
use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GurtError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Cryptographic error: {0}")]
Crypto(String),
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Invalid message format: {0}")]
InvalidMessage(String),
#[error("Connection error: {0}")]
Connection(String),
#[error("Handshake failed: {0}")]
Handshake(String),
#[error("Timeout error: {0}")]
Timeout(String),
#[error("Server error: {status} {message}")]
Server { status: u16, message: String },
#[error("Client error: {0}")]
Client(String),
}
pub type Result<T> = std::result::Result<T, GurtError>;
impl GurtError {
pub fn crypto<T: fmt::Display>(msg: T) -> Self {
GurtError::Crypto(msg.to_string())
}
pub fn protocol<T: fmt::Display>(msg: T) -> Self {
GurtError::Protocol(msg.to_string())
}
pub fn invalid_message<T: fmt::Display>(msg: T) -> Self {
GurtError::InvalidMessage(msg.to_string())
}
pub fn connection<T: fmt::Display>(msg: T) -> Self {
GurtError::Connection(msg.to_string())
}
pub fn handshake<T: fmt::Display>(msg: T) -> Self {
GurtError::Handshake(msg.to_string())
}
pub fn timeout<T: fmt::Display>(msg: T) -> Self {
GurtError::Timeout(msg.to_string())
}
pub fn server(status: u16, message: String) -> Self {
GurtError::Server { status, message }
}
pub fn client<T: fmt::Display>(msg: T) -> Self {
GurtError::Client(msg.to_string())
}
}

View File

@@ -0,0 +1,24 @@
pub mod protocol;
pub mod crypto;
pub mod server;
pub mod client;
pub mod error;
pub mod message;
pub use error::{GurtError, Result};
pub use message::{GurtMessage, GurtRequest, GurtResponse, GurtMethod};
pub use protocol::{GurtStatusCode, GURT_VERSION, DEFAULT_PORT};
pub use crypto::{CryptoManager, TlsConfig, GURT_ALPN, TLS_VERSION};
pub use server::{GurtServer, GurtHandler, ServerContext, Route};
pub use client::{GurtClient, ClientConfig};
pub mod prelude {
pub use crate::{
GurtError, Result,
GurtMessage, GurtRequest, GurtResponse,
GURT_VERSION, DEFAULT_PORT,
CryptoManager, TlsConfig, GURT_ALPN, TLS_VERSION,
GurtServer, GurtHandler, ServerContext, Route,
GurtClient, ClientConfig,
};
}

View File

@@ -0,0 +1,568 @@
use crate::{GurtError, Result, GURT_VERSION};
use crate::protocol::{GurtStatusCode, PROTOCOL_PREFIX, HEADER_SEPARATOR, BODY_SEPARATOR};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use std::fmt;
use chrono::Utc;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GurtMethod {
GET,
POST,
PUT,
DELETE,
HEAD,
OPTIONS,
PATCH,
HANDSHAKE, // Special method for protocol handshake
}
impl GurtMethod {
pub fn parse(s: &str) -> Result<Self> {
match s.to_uppercase().as_str() {
"GET" => Ok(Self::GET),
"POST" => Ok(Self::POST),
"PUT" => Ok(Self::PUT),
"DELETE" => Ok(Self::DELETE),
"HEAD" => Ok(Self::HEAD),
"OPTIONS" => Ok(Self::OPTIONS),
"PATCH" => Ok(Self::PATCH),
"HANDSHAKE" => Ok(Self::HANDSHAKE),
_ => Err(GurtError::invalid_message(format!("Unsupported method: {}", s))),
}
}
}
impl fmt::Display for GurtMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::GET => "GET",
Self::POST => "POST",
Self::PUT => "PUT",
Self::DELETE => "DELETE",
Self::HEAD => "HEAD",
Self::OPTIONS => "OPTIONS",
Self::PATCH => "PATCH",
Self::HANDSHAKE => "HANDSHAKE",
};
write!(f, "{}", s)
}
}
pub type GurtHeaders = HashMap<String, String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GurtRequest {
pub method: GurtMethod,
pub path: String,
pub version: String,
pub headers: GurtHeaders,
pub body: Vec<u8>,
}
impl GurtRequest {
pub fn new(method: GurtMethod, path: String) -> Self {
Self {
method,
path,
version: GURT_VERSION.to_string(),
headers: GurtHeaders::new(),
body: Vec::new(),
}
}
pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.headers.insert(key.into().to_lowercase(), value.into());
self
}
pub fn with_body<B: Into<Vec<u8>>>(mut self, body: B) -> Self {
self.body = body.into();
self
}
pub fn with_string_body<S: AsRef<str>>(mut self, body: S) -> Self {
self.body = body.as_ref().as_bytes().to_vec();
self
}
pub fn header(&self, key: &str) -> Option<&String> {
self.headers.get(&key.to_lowercase())
}
pub fn body_as_string(&self) -> Result<String> {
std::str::from_utf8(&self.body)
.map(|s| s.to_string())
.map_err(|e| GurtError::invalid_message(format!("Invalid UTF-8 body: {}", e)))
}
pub fn parse(data: &str) -> Result<Self> {
Self::parse_bytes(data.as_bytes())
}
pub fn parse_bytes(data: &[u8]) -> Result<Self> {
// Find the header/body separator as bytes
let body_separator = BODY_SEPARATOR.as_bytes();
let body_separator_pos = data.windows(body_separator.len())
.position(|window| window == body_separator);
let (headers_section, body) = if let Some(pos) = body_separator_pos {
let headers_part = &data[..pos];
let body_part = &data[pos + body_separator.len()..];
(headers_part, body_part.to_vec())
} else {
(data, Vec::new())
};
// Convert headers section to string (should be valid UTF-8)
let headers_str = std::str::from_utf8(headers_section)
.map_err(|_| GurtError::invalid_message("Invalid UTF-8 in headers"))?;
let lines: Vec<&str> = headers_str.split(HEADER_SEPARATOR).collect();
if lines.is_empty() {
return Err(GurtError::invalid_message("Empty request"));
}
// Parse request line (METHOD path GURT/version)
let request_line = lines[0];
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() != 3 {
return Err(GurtError::invalid_message("Invalid request line format"));
}
let method = GurtMethod::parse(parts[0])?;
let path = parts[1].to_string();
// Parse protocol version
if !parts[2].starts_with(PROTOCOL_PREFIX) {
return Err(GurtError::invalid_message("Invalid protocol identifier"));
}
let version_str = &parts[2][PROTOCOL_PREFIX.len()..];
let version = version_str.to_string();
// Parse headers
let mut headers = GurtHeaders::new();
for line in lines.iter().skip(1) {
if line.is_empty() {
break;
}
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim().to_lowercase();
let value = line[colon_pos + 1..].trim().to_string();
headers.insert(key, value);
}
}
Ok(Self {
method,
path,
version,
headers,
body,
})
}
pub fn to_string(&self) -> String {
let mut message = format!("{} {} {}{}{}",
self.method, self.path, PROTOCOL_PREFIX, self.version, HEADER_SEPARATOR);
let mut headers = self.headers.clone();
if !headers.contains_key("content-length") {
headers.insert("content-length".to_string(), self.body.len().to_string());
}
if !headers.contains_key("user-agent") {
headers.insert("user-agent".to_string(), format!("GURT-Client/{}", GURT_VERSION));
}
for (key, value) in &headers {
message.push_str(&format!("{}: {}{}", key, value, HEADER_SEPARATOR));
}
message.push_str(HEADER_SEPARATOR);
if !self.body.is_empty() {
if let Ok(body_str) = std::str::from_utf8(&self.body) {
message.push_str(body_str);
}
}
message
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut message = format!("{} {} {}{}{}",
self.method, self.path, PROTOCOL_PREFIX, self.version, HEADER_SEPARATOR);
let mut headers = self.headers.clone();
if !headers.contains_key("content-length") {
headers.insert("content-length".to_string(), self.body.len().to_string());
}
if !headers.contains_key("user-agent") {
headers.insert("user-agent".to_string(), format!("GURT-Client/{}", GURT_VERSION));
}
for (key, value) in &headers {
message.push_str(&format!("{}: {}{}", key, value, HEADER_SEPARATOR));
}
message.push_str(HEADER_SEPARATOR);
let mut bytes = message.into_bytes();
bytes.extend_from_slice(&self.body);
bytes
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GurtResponse {
pub version: String,
pub status_code: u16,
pub status_message: String,
pub headers: GurtHeaders,
pub body: Vec<u8>,
}
impl GurtResponse {
pub fn new(status_code: GurtStatusCode) -> Self {
Self {
version: GURT_VERSION.to_string(),
status_code: status_code as u16,
status_message: status_code.message().to_string(),
headers: GurtHeaders::new(),
body: Vec::new(),
}
}
pub fn ok() -> Self {
Self::new(GurtStatusCode::Ok)
}
pub fn not_found() -> Self {
Self::new(GurtStatusCode::NotFound)
}
pub fn bad_request() -> Self {
Self::new(GurtStatusCode::BadRequest)
}
pub fn internal_server_error() -> Self {
Self::new(GurtStatusCode::InternalServerError)
}
pub fn with_header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.headers.insert(key.into().to_lowercase(), value.into());
self
}
pub fn with_body<B: Into<Vec<u8>>>(mut self, body: B) -> Self {
self.body = body.into();
self
}
pub fn with_string_body<S: AsRef<str>>(mut self, body: S) -> Self {
self.body = body.as_ref().as_bytes().to_vec();
self
}
pub fn with_json_body<T: Serialize>(mut self, data: &T) -> Result<Self> {
let json = serde_json::to_string(data)?;
self.body = json.into_bytes();
self.headers.insert("content-type".to_string(), "application/json".to_string());
Ok(self)
}
pub fn header(&self, key: &str) -> Option<&String> {
self.headers.get(&key.to_lowercase())
}
pub fn body_as_string(&self) -> Result<String> {
std::str::from_utf8(&self.body)
.map(|s| s.to_owned())
.map_err(|e| GurtError::invalid_message(format!("Invalid UTF-8 body: {}", e)))
}
pub fn is_success(&self) -> bool {
self.status_code >= 200 && self.status_code < 300
}
pub fn is_client_error(&self) -> bool {
self.status_code >= 400 && self.status_code < 500
}
pub fn is_server_error(&self) -> bool {
self.status_code >= 500
}
pub fn parse(data: &str) -> Result<Self> {
Self::parse_bytes(data.as_bytes())
}
pub fn parse_bytes(data: &[u8]) -> Result<Self> {
// Find the header/body separator as bytes
let body_separator = BODY_SEPARATOR.as_bytes();
let body_separator_pos = data.windows(body_separator.len())
.position(|window| window == body_separator);
let (headers_section, body) = if let Some(pos) = body_separator_pos {
let headers_part = &data[..pos];
let body_part = &data[pos + body_separator.len()..];
(headers_part, body_part.to_vec())
} else {
(data, Vec::new())
};
// Convert headers section to string (should be valid UTF-8)
let headers_str = std::str::from_utf8(headers_section)
.map_err(|_| GurtError::invalid_message("Invalid UTF-8 in headers"))?;
let lines: Vec<&str> = headers_str.split(HEADER_SEPARATOR).collect();
if lines.is_empty() {
return Err(GurtError::invalid_message("Empty response"));
}
// Parse status line (GURT/version status_code status_message)
let status_line = lines[0];
let parts: Vec<&str> = status_line.splitn(3, ' ').collect();
if parts.len() < 2 {
return Err(GurtError::invalid_message("Invalid status line format"));
}
// Parse protocol version
if !parts[0].starts_with(PROTOCOL_PREFIX) {
return Err(GurtError::invalid_message("Invalid protocol identifier"));
}
let version_str = &parts[0][PROTOCOL_PREFIX.len()..];
let version = version_str.to_string();
let status_code: u16 = parts[1].parse()
.map_err(|_| GurtError::invalid_message("Invalid status code"))?;
let status_message = if parts.len() > 2 {
parts[2].to_string()
} else {
GurtStatusCode::from_u16(status_code)
.map(|sc| sc.message().to_string())
.unwrap_or_else(|| "Unknown".to_string())
};
// Parse headers
let mut headers = GurtHeaders::new();
for line in lines.iter().skip(1) {
if line.is_empty() {
break;
}
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim().to_lowercase();
let value = line[colon_pos + 1..].trim().to_string();
headers.insert(key, value);
}
}
Ok(Self {
version,
status_code,
status_message,
headers,
body,
})
}
pub fn to_string(&self) -> String {
let mut message = format!("{}{} {} {}{}",
PROTOCOL_PREFIX, self.version, self.status_code, self.status_message, HEADER_SEPARATOR);
let mut headers = self.headers.clone();
if !headers.contains_key("content-length") {
headers.insert("content-length".to_string(), self.body.len().to_string());
}
if !headers.contains_key("server") {
headers.insert("server".to_string(), format!("GURT/{}", GURT_VERSION));
}
if !headers.contains_key("date") {
// RFC 7231 compliant
let now = Utc::now();
let date_str = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
headers.insert("date".to_string(), date_str);
}
for (key, value) in &headers {
message.push_str(&format!("{}: {}{}", key, value, HEADER_SEPARATOR));
}
message.push_str(HEADER_SEPARATOR);
if !self.body.is_empty() {
if let Ok(body_str) = std::str::from_utf8(&self.body) {
message.push_str(body_str);
}
}
message
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut message = format!("{}{} {} {}{}",
PROTOCOL_PREFIX, self.version, self.status_code, self.status_message, HEADER_SEPARATOR);
let mut headers = self.headers.clone();
if !headers.contains_key("content-length") {
headers.insert("content-length".to_string(), self.body.len().to_string());
}
if !headers.contains_key("server") {
headers.insert("server".to_string(), format!("GURT/{}", GURT_VERSION));
}
if !headers.contains_key("date") {
// RFC 7231 compliant
let now = Utc::now();
let date_str = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
headers.insert("date".to_string(), date_str);
}
for (key, value) in &headers {
message.push_str(&format!("{}: {}{}", key, value, HEADER_SEPARATOR));
}
message.push_str(HEADER_SEPARATOR);
// Convert headers to bytes and append body as raw bytes
let mut bytes = message.into_bytes();
bytes.extend_from_slice(&self.body);
bytes
}
}
#[derive(Debug, Clone)]
pub enum GurtMessage {
Request(GurtRequest),
Response(GurtResponse),
}
impl GurtMessage {
pub fn parse(data: &str) -> Result<Self> {
Self::parse_bytes(data.as_bytes())
}
pub fn parse_bytes(data: &[u8]) -> Result<Self> {
// Convert first line to string to determine message type
let header_separator = HEADER_SEPARATOR.as_bytes();
let first_line_end = data.windows(header_separator.len())
.position(|window| window == header_separator)
.unwrap_or(data.len());
let first_line = std::str::from_utf8(&data[..first_line_end])
.map_err(|_| GurtError::invalid_message("Invalid UTF-8 in first line"))?;
// Check if it's a response (starts with GURT/version) or request (method first)
if first_line.starts_with(PROTOCOL_PREFIX) {
Ok(GurtMessage::Response(GurtResponse::parse_bytes(data)?))
} else {
Ok(GurtMessage::Request(GurtRequest::parse_bytes(data)?))
}
}
pub fn is_request(&self) -> bool {
matches!(self, GurtMessage::Request(_))
}
pub fn is_response(&self) -> bool {
matches!(self, GurtMessage::Response(_))
}
pub fn as_request(&self) -> Option<&GurtRequest> {
match self {
GurtMessage::Request(req) => Some(req),
_ => None,
}
}
pub fn as_response(&self) -> Option<&GurtResponse> {
match self {
GurtMessage::Response(res) => Some(res),
_ => None,
}
}
}
impl fmt::Display for GurtMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GurtMessage::Request(req) => write!(f, "{}", req.to_string()),
GurtMessage::Response(res) => write!(f, "{}", res.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_request_parsing() {
let raw = "GET /test GURT/1.0.0\r\nHost: example.com\r\nAccept: text/html\r\n\r\ntest body";
let request = GurtRequest::parse(raw).expect("Failed to parse request");
assert_eq!(request.method, GurtMethod::GET);
assert_eq!(request.path, "/test");
assert_eq!(request.version, GURT_VERSION.to_string());
assert_eq!(request.header("host"), Some(&"example.com".to_string()));
assert_eq!(request.header("accept"), Some(&"text/html".to_string()));
assert_eq!(request.body_as_string().unwrap(), "test body");
}
#[test]
fn test_response_parsing() {
let raw = "GURT/1.0.0 200 OK\r\nContent-Type: text/html\r\n\r\n<html></html>";
let response = GurtResponse::parse(raw).expect("Failed to parse response");
assert_eq!(response.version, GURT_VERSION.to_string());
assert_eq!(response.status_code, 200);
assert_eq!(response.status_message, "OK");
assert_eq!(response.header("content-type"), Some(&"text/html".to_string()));
assert_eq!(response.body_as_string().unwrap(), "<html></html>");
}
#[test]
fn test_request_building() {
let request = GurtRequest::new(GurtMethod::GET, "/test".to_string())
.with_header("Host", "example.com")
.with_string_body("test body");
let raw = request.to_string();
let parsed = GurtRequest::parse(&raw).expect("Failed to parse built request");
assert_eq!(parsed.method, request.method);
assert_eq!(parsed.path, request.path);
assert_eq!(parsed.body, request.body);
}
#[test]
fn test_response_building() {
let response = GurtResponse::ok()
.with_header("Content-Type", "text/html")
.with_string_body("<html></html>");
let raw = response.to_string();
let parsed = GurtResponse::parse(&raw).expect("Failed to parse built response");
assert_eq!(parsed.status_code, response.status_code);
assert_eq!(parsed.body, response.body);
}
}

View File

@@ -0,0 +1,120 @@
use std::fmt;
pub const GURT_VERSION: &str = "1.0.0";
pub const DEFAULT_PORT: u16 = 4878;
pub const PROTOCOL_PREFIX: &str = "GURT/";
pub const HEADER_SEPARATOR: &str = "\r\n";
pub const BODY_SEPARATOR: &str = "\r\n\r\n";
pub const DEFAULT_HANDSHAKE_TIMEOUT: u64 = 5;
pub const DEFAULT_REQUEST_TIMEOUT: u64 = 30;
pub const DEFAULT_CONNECTION_TIMEOUT: u64 = 10;
pub const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024;
pub const MAX_POOL_SIZE: usize = 10;
pub const POOL_IDLE_TIMEOUT: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GurtStatusCode {
// Success
Ok = 200,
Created = 201,
Accepted = 202,
NoContent = 204,
// Handshake
SwitchingProtocols = 101,
// Client errors
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
Timeout = 408,
TooLarge = 413,
UnsupportedMediaType = 415,
// Server errors
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTimeout = 504,
}
impl GurtStatusCode {
pub fn from_u16(code: u16) -> Option<Self> {
match code {
200 => Some(Self::Ok),
201 => Some(Self::Created),
202 => Some(Self::Accepted),
204 => Some(Self::NoContent),
101 => Some(Self::SwitchingProtocols),
400 => Some(Self::BadRequest),
401 => Some(Self::Unauthorized),
403 => Some(Self::Forbidden),
404 => Some(Self::NotFound),
405 => Some(Self::MethodNotAllowed),
408 => Some(Self::Timeout),
413 => Some(Self::TooLarge),
415 => Some(Self::UnsupportedMediaType),
500 => Some(Self::InternalServerError),
501 => Some(Self::NotImplemented),
502 => Some(Self::BadGateway),
503 => Some(Self::ServiceUnavailable),
504 => Some(Self::GatewayTimeout),
_ => None,
}
}
pub fn message(&self) -> &'static str {
match self {
Self::Ok => "OK",
Self::Created => "CREATED",
Self::Accepted => "ACCEPTED",
Self::NoContent => "NO_CONTENT",
Self::SwitchingProtocols => "SWITCHING_PROTOCOLS",
Self::BadRequest => "BAD_REQUEST",
Self::Unauthorized => "UNAUTHORIZED",
Self::Forbidden => "FORBIDDEN",
Self::NotFound => "NOT_FOUND",
Self::MethodNotAllowed => "METHOD_NOT_ALLOWED",
Self::Timeout => "TIMEOUT",
Self::TooLarge => "TOO_LARGE",
Self::UnsupportedMediaType => "UNSUPPORTED_MEDIA_TYPE",
Self::InternalServerError => "INTERNAL_SERVER_ERROR",
Self::NotImplemented => "NOT_IMPLEMENTED",
Self::BadGateway => "BAD_GATEWAY",
Self::ServiceUnavailable => "SERVICE_UNAVAILABLE",
Self::GatewayTimeout => "GATEWAY_TIMEOUT",
}
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Ok | Self::Created | Self::Accepted | Self::NoContent)
}
pub fn is_client_error(&self) -> bool {
(*self as u16) >= 400 && (*self as u16) < 500
}
pub fn is_server_error(&self) -> bool {
(*self as u16) >= 500
}
}
impl From<GurtStatusCode> for u16 {
fn from(code: GurtStatusCode) -> Self {
code as u16
}
}
impl fmt::Display for GurtStatusCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", *self as u16)
}
}

View File

@@ -0,0 +1,563 @@
use crate::{
GurtError, Result, GurtRequest, GurtResponse, GurtMessage,
protocol::{BODY_SEPARATOR, MAX_MESSAGE_SIZE},
message::GurtMethod,
protocol::GurtStatusCode,
crypto::{TLS_VERSION, GURT_ALPN, TlsConfig},
};
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_rustls::{TlsAcceptor, server::TlsStream};
use rustls::pki_types::CertificateDer;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::fs;
use tracing::{info, warn, error, debug};
#[derive(Debug, Clone)]
pub struct ServerContext {
pub remote_addr: SocketAddr,
pub request: GurtRequest,
}
impl ServerContext {
pub fn client_ip(&self) -> std::net::IpAddr {
self.remote_addr.ip()
}
pub fn client_port(&self) -> u16 {
self.remote_addr.port()
}
pub fn method(&self) -> &GurtMethod {
&self.request.method
}
pub fn path(&self) -> &str {
&self.request.path
}
pub fn headers(&self) -> &HashMap<String, String> {
&self.request.headers
}
pub fn body(&self) -> &[u8] {
&self.request.body
}
pub fn body_as_string(&self) -> Result<String> {
self.request.body_as_string()
}
pub fn header(&self, key: &str) -> Option<&String> {
self.request.header(key)
}
}
pub trait GurtHandler: Send + Sync {
fn handle(&self, ctx: &ServerContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GurtResponse>> + Send + '_>>;
}
pub struct FnHandler<F> {
handler: F,
}
impl<F, Fut> GurtHandler for FnHandler<F>
where
F: Fn(&ServerContext) -> Fut + Send + Sync,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
fn handle(&self, ctx: &ServerContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GurtResponse>> + Send + '_>> {
Box::pin((self.handler)(ctx))
}
}
#[derive(Debug, Clone)]
pub struct Route {
method: Option<GurtMethod>,
path_pattern: String,
}
impl Route {
pub fn new(method: Option<GurtMethod>, path_pattern: String) -> Self {
Self { method, path_pattern }
}
pub fn get(path: &str) -> Self {
Self::new(Some(GurtMethod::GET), path.to_string())
}
pub fn post(path: &str) -> Self {
Self::new(Some(GurtMethod::POST), path.to_string())
}
pub fn put(path: &str) -> Self {
Self::new(Some(GurtMethod::PUT), path.to_string())
}
pub fn delete(path: &str) -> Self {
Self::new(Some(GurtMethod::DELETE), path.to_string())
}
pub fn head(path: &str) -> Self {
Self::new(Some(GurtMethod::HEAD), path.to_string())
}
pub fn options(path: &str) -> Self {
Self::new(Some(GurtMethod::OPTIONS), path.to_string())
}
pub fn patch(path: &str) -> Self {
Self::new(Some(GurtMethod::PATCH), path.to_string())
}
pub fn any(path: &str) -> Self {
Self::new(None, path.to_string())
}
pub fn matches(&self, method: &GurtMethod, path: &str) -> bool {
if let Some(route_method) = &self.method {
if route_method != method {
return false;
}
}
self.matches_path(path)
}
pub fn matches_path(&self, path: &str) -> bool {
self.path_pattern == path ||
(self.path_pattern.ends_with('*') && path.starts_with(&self.path_pattern[..self.path_pattern.len()-1]))
}
}
pub struct GurtServer {
routes: Vec<(Route, Arc<dyn GurtHandler>)>,
tls_acceptor: Option<TlsAcceptor>,
}
impl GurtServer {
pub fn new() -> Self {
Self {
routes: Vec::new(),
tls_acceptor: None,
}
}
pub fn with_tls_certificates(cert_path: &str, key_path: &str) -> Result<Self> {
let mut server = Self::new();
server.load_tls_certificates(cert_path, key_path)?;
Ok(server)
}
pub fn load_tls_certificates(&mut self, cert_path: &str, key_path: &str) -> Result<()> {
info!("Loading TLS certificates: cert={}, key={}", cert_path, key_path);
let cert_data = fs::read(cert_path)
.map_err(|e| GurtError::crypto(format!("Failed to read certificate file '{}': {}", cert_path, e)))?;
let key_data = fs::read(key_path)
.map_err(|e| GurtError::crypto(format!("Failed to read private key file '{}': {}", key_path, e)))?;
let mut cursor = std::io::Cursor::new(cert_data);
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cursor)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| GurtError::crypto(format!("Failed to parse certificates: {}", e)))?;
if certs.is_empty() {
return Err(GurtError::crypto("No certificates found in certificate file"));
}
let mut key_cursor = std::io::Cursor::new(key_data);
let private_key = rustls_pemfile::private_key(&mut key_cursor)
.map_err(|e| GurtError::crypto(format!("Failed to parse private key: {}", e)))?
.ok_or_else(|| GurtError::crypto("No private key found in key file"))?;
let tls_config = TlsConfig::new_server(certs, private_key)?;
self.tls_acceptor = Some(tls_config.get_acceptor()?);
info!("TLS certificates loaded successfully");
Ok(())
}
pub fn route<H>(mut self, route: Route, handler: H) -> Self
where
H: GurtHandler + 'static,
{
self.routes.push((route, Arc::new(handler)));
self
}
pub fn get<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::get(path), FnHandler { handler })
}
pub fn post<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::post(path), FnHandler { handler })
}
pub fn put<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::put(path), FnHandler { handler })
}
pub fn delete<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::delete(path), FnHandler { handler })
}
pub fn head<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::head(path), FnHandler { handler })
}
pub fn options<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::options(path), FnHandler { handler })
}
pub fn patch<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::patch(path), FnHandler { handler })
}
pub fn any<F, Fut>(self, path: &str, handler: F) -> Self
where
F: Fn(&ServerContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<GurtResponse>> + Send + 'static,
{
self.route(Route::any(path), FnHandler { handler })
}
pub async fn listen(self, addr: &str) -> Result<()> {
let listener = TcpListener::bind(addr).await?;
info!("GURT server listening on {}", addr);
loop {
match listener.accept().await {
Ok((stream, addr)) => {
info!("Client connected: {}", addr);
let server = self.clone();
tokio::spawn(async move {
if let Err(e) = server.handle_connection(stream, addr).await {
error!("Connection error from {}: {}", addr, e);
}
info!("Client disconnected: {}", addr);
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
}
async fn handle_connection(&self, mut stream: TcpStream, addr: SocketAddr) -> Result<()> {
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"))
}
}
async fn handle_initial_handshake(&self, stream: &mut TcpStream, addr: SocketAddr) -> Result<()> {
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"))
}
}
}
async fn handle_tls_connection(&self, mut tls_stream: TlsStream<TcpStream>, addr: SocketAddr) -> Result<()> {
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
loop {
let bytes_read = match tls_stream.read(&mut temp_buffer).await {
Ok(n) => n,
Err(e) => {
// Handle UnexpectedEof from clients that don't send close_notify
if e.kind() == std::io::ErrorKind::UnexpectedEof {
debug!("Client {} closed connection without TLS close_notify (benign)", addr);
break;
}
return Err(e.into());
}
};
if bytes_read == 0 {
break; // Connection closed
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
let body_separator = BODY_SEPARATOR.as_bytes();
let has_complete_message = buffer.windows(body_separator.len()).any(|w| w == body_separator) ||
(buffer.starts_with(b"{") && buffer.ends_with(b"}"));
if has_complete_message {
if let Err(e) = self.process_tls_message(&mut tls_stream, addr, &buffer).await {
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;
}
buffer.clear();
}
// Prevent buffer overflow
if buffer.len() > MAX_MESSAGE_SIZE {
warn!("Message too large from {}, closing connection", addr);
break;
}
}
Ok(())
}
async fn send_handshake_response(&self, stream: &mut TcpStream, addr: SocketAddr, _request: &GurtRequest) -> Result<()> {
info!("Sending handshake response to {}", addr);
let response = GurtResponse::new(GurtStatusCode::SwitchingProtocols)
.with_header("GURT-Version", crate::GURT_VERSION.to_string())
.with_header("Encryption", TLS_VERSION)
.with_header("ALPN", std::str::from_utf8(GURT_ALPN).unwrap_or("gurt/1.0"));
let response_bytes = response.to_string().into_bytes();
stream.write_all(&response_bytes).await?;
info!("Handshake response sent to {}, preparing for TLS upgrade", addr);
Ok(())
}
async fn process_tls_message(&self, tls_stream: &mut TlsStream<TcpStream>, addr: SocketAddr, data: &[u8]) -> Result<()> {
let message = GurtMessage::parse_bytes(data)?;
match message {
GurtMessage::Request(request) => {
if request.method == GurtMethod::HANDSHAKE {
Err(GurtError::protocol("Received HANDSHAKE over TLS - protocol violation"))
} else {
self.handle_encrypted_request(tls_stream, addr, &request).await
}
}
GurtMessage::Response(_) => {
warn!("Received response on server, ignoring");
Ok(())
}
}
}
async fn handle_default_options(&self, tls_stream: &mut TlsStream<TcpStream>, request: &GurtRequest) -> Result<()> {
let mut allowed_methods = std::collections::HashSet::new();
for (route, _) in &self.routes {
if route.matches_path(&request.path) {
if let Some(method) = &route.method {
allowed_methods.insert(method.to_string());
} else {
// Route matches any method
allowed_methods.extend(vec![
"GET".to_string(), "POST".to_string(), "PUT".to_string(),
"DELETE".to_string(), "HEAD".to_string(), "PATCH".to_string()
]);
}
}
}
allowed_methods.insert("OPTIONS".to_string());
let mut allowed_methods_vec: Vec<String> = allowed_methods.into_iter().collect();
allowed_methods_vec.sort();
let allow_header = allowed_methods_vec.join(", ");
let response = GurtResponse::ok()
.with_header("Allow", allow_header)
.with_header("Access-Control-Allow-Origin", "*")
.with_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH")
.with_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
tls_stream.write_all(&response.to_bytes()).await?;
Ok(())
}
async fn handle_default_head(&self, tls_stream: &mut TlsStream<TcpStream>, addr: SocketAddr, request: &GurtRequest) -> Result<()> {
for (route, handler) in &self.routes {
if route.method == Some(GurtMethod::GET) && route.matches(&GurtMethod::GET, &request.path) {
let context = ServerContext {
remote_addr: addr,
request: request.clone(),
};
match handler.handle(&context).await {
Ok(mut response) => {
let original_content_length = response.body.len();
response.body.clear();
response = response.with_header("content-length", original_content_length.to_string());
tls_stream.write_all(&response.to_bytes()).await?;
return Ok(());
}
Err(e) => {
error!("Handler error for HEAD {} (via GET): {}", request.path, e);
let error_response = GurtResponse::internal_server_error();
tls_stream.write_all(&error_response.to_bytes()).await?;
return Ok(());
}
}
}
}
let not_found_response = GurtResponse::not_found();
tls_stream.write_all(&not_found_response.to_bytes()).await?;
Ok(())
}
async fn handle_encrypted_request(&self, tls_stream: &mut TlsStream<TcpStream>, addr: SocketAddr, request: &GurtRequest) -> Result<()> {
debug!("Handling encrypted {} request to {} from {}", request.method, request.path, addr);
// Find matching route
for (route, handler) in &self.routes {
if route.matches(&request.method, &request.path) {
let context = ServerContext {
remote_addr: addr,
request: request.clone(),
};
match handler.handle(&context).await {
Ok(response) => {
// Use to_bytes() to avoid corrupting binary data
let response_bytes = response.to_bytes();
tls_stream.write_all(&response_bytes).await?;
return Ok(());
}
Err(e) => {
error!("Handler error for {} {}: {}", request.method, request.path, e);
let error_response = GurtResponse::internal_server_error()
.with_string_body("Internal server error");
tls_stream.write_all(&error_response.to_bytes()).await?;
return Ok(());
}
}
}
}
// No route found - check for default OPTIONS/HEAD handling
match request.method {
GurtMethod::OPTIONS => {
self.handle_default_options(tls_stream, request).await
}
GurtMethod::HEAD => {
self.handle_default_head(tls_stream, addr, request).await
}
_ => {
let not_found_response = GurtResponse::not_found()
.with_string_body("Not found");
tls_stream.write_all(&not_found_response.to_bytes()).await?;
Ok(())
}
}
}
}
impl Clone for GurtServer {
fn clone(&self) -> Self {
Self {
routes: self.routes.clone(),
tls_acceptor: self.tls_acceptor.clone(),
}
}
}
impl Default for GurtServer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::test;
#[test]
async fn test_route_matching() {
let route = Route::get("/test");
assert!(route.matches(&GurtMethod::GET, "/test"));
assert!(!route.matches(&GurtMethod::POST, "/test"));
assert!(!route.matches(&GurtMethod::GET, "/other"));
let wildcard_route = Route::get("/api/*");
assert!(wildcard_route.matches(&GurtMethod::GET, "/api/users"));
assert!(wildcard_route.matches(&GurtMethod::GET, "/api/posts"));
assert!(!wildcard_route.matches(&GurtMethod::GET, "/other"));
}
}