GURT protocol (lib, cli, gdextension, Flumi integration)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -0,0 +1,2 @@
|
||||
*target*
|
||||
*.pem
|
||||
169
SPEC.md
Normal file
169
SPEC.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
23
dns/migrations/005_add_dns_records.sql
Normal file
23
dns/migrations/005_add_dns_records.sql
Normal 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';
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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": "⚠️"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,5 +32,4 @@ static func get_user_agent() -> String:
|
||||
godot_version.minor,
|
||||
godot_version.patch
|
||||
]
|
||||
print(user_agent)
|
||||
return user_agent
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
BIN
flumi/addons/gurt-protocol/bin/windows/gurt_godot.dll
Normal file
BIN
flumi/addons/gurt-protocol/bin/windows/gurt_godot.dll
Normal file
Binary file not shown.
13
flumi/addons/gurt-protocol/gurt_godot.gdextension
Normal file
13
flumi/addons/gurt-protocol/gurt_godot.gdextension
Normal 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"
|
||||
1
flumi/addons/gurt-protocol/gurt_godot.gdextension.uid
Normal file
1
flumi/addons/gurt-protocol/gurt_godot.gdextension.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgbwo0xlp5gya
|
||||
7
flumi/addons/gurt-protocol/plugin.cfg
Normal file
7
flumi/addons/gurt-protocol/plugin.cfg
Normal 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"
|
||||
8
flumi/addons/gurt-protocol/plugin.gd
Normal file
8
flumi/addons/gurt-protocol/plugin.gd
Normal file
@@ -0,0 +1,8 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
func _enter_tree():
|
||||
print("GURT Protocol plugin enabled")
|
||||
|
||||
func _exit_tree():
|
||||
print("GURT Protocol plugin disabled")
|
||||
1
flumi/addons/gurt-protocol/plugin.gd.uid
Normal file
1
flumi/addons/gurt-protocol/plugin.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://tt2hh4j8txne
|
||||
@@ -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
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
25
protocol/cli/Cargo.toml
Normal 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
56
protocol/cli/README.md
Normal 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
307
protocol/cli/src/main.rs
Normal 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
25
protocol/gdextension/.gitignore
vendored
Normal 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/
|
||||
37
protocol/gdextension/Cargo.toml
Normal file
37
protocol/gdextension/Cargo.toml
Normal 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
|
||||
37
protocol/gdextension/README.md
Normal file
37
protocol/gdextension/README.md
Normal 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
|
||||
```
|
||||
153
protocol/gdextension/build.sh
Normal file
153
protocol/gdextension/build.sh
Normal 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"
|
||||
13
protocol/gdextension/gurt_godot.gdextension
Normal file
13
protocol/gdextension/gurt_godot.gdextension
Normal 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"
|
||||
7
protocol/gdextension/plugin.cfg
Normal file
7
protocol/gdextension/plugin.cfg
Normal 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"
|
||||
8
protocol/gdextension/plugin.gd
Normal file
8
protocol/gdextension/plugin.gd
Normal file
@@ -0,0 +1,8 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
func _enter_tree():
|
||||
print("GURT Protocol plugin enabled")
|
||||
|
||||
func _exit_tree():
|
||||
print("GURT Protocol plugin disabled")
|
||||
375
protocol/gdextension/src/lib.rs
Normal file
375
protocol/gdextension/src/lib.rs
Normal 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
1540
protocol/library/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
protocol/library/Cargo.toml
Normal file
46
protocol/library/Cargo.toml
Normal 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
|
||||
3
protocol/library/README.md
Normal file
3
protocol/library/README.md
Normal 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.
|
||||
18
protocol/library/examples/tls_server.rs
Normal file
18
protocol/library/examples/tls_server.rs
Normal 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
|
||||
}
|
||||
302
protocol/library/src/client.rs
Normal file
302
protocol/library/src/client.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
123
protocol/library/src/crypto.rs
Normal file
123
protocol/library/src/crypto.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
71
protocol/library/src/error.rs
Normal file
71
protocol/library/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
24
protocol/library/src/lib.rs
Normal file
24
protocol/library/src/lib.rs
Normal 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,
|
||||
};
|
||||
}
|
||||
568
protocol/library/src/message.rs
Normal file
568
protocol/library/src/message.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
protocol/library/src/protocol.rs
Normal file
120
protocol/library/src/protocol.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
563
protocol/library/src/server.rs
Normal file
563
protocol/library/src/server.rs
Normal 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(¬_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(¬_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"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user