diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 23b976b..0000000 --- a/SPEC.md +++ /dev/null @@ -1,169 +0,0 @@ -# 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 ` -- **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. \ No newline at end of file diff --git a/docs/docs/dns-system.md b/docs/docs/dns-system.md new file mode 100644 index 0000000..932d9eb --- /dev/null +++ b/docs/docs/dns-system.md @@ -0,0 +1,13 @@ +--- +sidebar_position: 6 +--- + +# DNS + +The Gurted ecosystem features a custom DNS system that enables domain resolution for the gurt:// protocol. Unlike traditional DNS, Gurted DNS is designed specifically for the decentralized web ecosystem, providing: + +- domain registration +- approval workflows (to ensure a free-of-charge, spam-free experience) +- archivable domain records + +TODO: complete \ No newline at end of file diff --git a/docs/docs/flumi-browser.md b/docs/docs/flumi-browser.md new file mode 100644 index 0000000..854b00c --- /dev/null +++ b/docs/docs/flumi-browser.md @@ -0,0 +1,8 @@ +--- +sidebar_position: 7 +--- + +# Flumi (browser) + +**Flumi** is the official browser for the Gurted ecosystem, built using the Godot game engine. It provides a complete web browsing experience for `gurt://` URLs with custom HTML/CSS rendering, Lua scripting support, and integration with the Gurted DNS system. + diff --git a/docs/docs/gurt-client.md b/docs/docs/gurt-client.md new file mode 100644 index 0000000..1c9ef38 --- /dev/null +++ b/docs/docs/gurt-client.md @@ -0,0 +1,352 @@ +--- +sidebar_position: 3 +--- + +# GURT Client Library + +The GURT client library (for Rust) provides a high-level, HTTP-like interface for making requests to GURT servers. It handles TLS encryption, protocol handshakes, and connection management automatically. + +## Bindings +- **Godot** by Gurted - [🔗 link](https://gurted.com/download) +- No bidings for other languages are currently available. + +## Installation + +Install via Cargo: +```bash +cargo add gurt +``` + +## Quick Start + +```rust +use gurt::prelude::*; + +#[tokio::main] +async fn main() -> Result<()> { + let client = GurtClient::new(); + + // Make a GET request + let response = client.get("gurt://example.com/").await?; + + println!("Status: {}", response.status_code); + println!("Body: {}", response.text()?); + + Ok(()) +} +``` + +## Creating a Client + +### Default Client + +```rust +let client = GurtClient::new(); +``` + +### Custom Configuration + +```rust +use tokio::time::Duration; + +let config = GurtClientConfig { + connect_timeout: Duration::from_secs(10), + request_timeout: Duration::from_secs(30), + handshake_timeout: Duration::from_secs(5), + user_agent: "MyApp/1.0.0".to_string(), + max_redirects: 5, +}; + +let client = GurtClient::with_config(config); +``` + +## Making Requests + +### GET Requests + +```rust +let response = client.get("gurt://api.example.com/users").await?; + +if response.is_success() { + println!("Success: {}", response.text()?); +} else { + println!("Error: {} {}", response.status_code, response.status_message); +} +``` + +### POST Requests + +#### Text Data +```rust +let response = client.post("gurt://api.example.com/submit", "Hello, GURT!").await?; +``` + +#### JSON Data +```rust +use serde_json::json; + +let data = json!({ + "name": "John Doe", + "email": "john@example.com" +}); + +let response = client.post_json("gurt://api.example.com/users", &data).await?; +``` + +### PUT Requests + +```rust +// Text data +let response = client.put("gurt://api.example.com/resource/123", "Updated content").await?; + +// JSON data +let update_data = json!({"status": "completed"}); +let response = client.put_json("gurt://api.example.com/tasks/456", &update_data).await?; +``` + +### DELETE Requests + +```rust +let response = client.delete("gurt://api.example.com/users/123").await?; +``` + +### HEAD Requests + +```rust +let response = client.head("gurt://api.example.com/large-file").await?; + +// Check headers without downloading body +let content_length = response.headers.get("content-length"); +``` + +### OPTIONS Requests + +```rust +let response = client.options("gurt://api.example.com/endpoint").await?; + +// Check allowed methods +let allowed_methods = response.headers.get("allow"); +``` + +### PATCH Requests + +```rust +let patch_data = json!({"name": "Updated Name"}); +let response = client.patch_json("gurt://api.example.com/users/123", &patch_data).await?; +``` + +## Response Handling + +### Response Structure + +```rust +pub struct GurtResponse { + pub version: String, + pub status_code: u16, + pub status_message: String, + pub headers: HashMap, + pub body: Vec, +} +``` + +### Accessing Response Data + +```rust +let response = client.get("gurt://api.example.com/data").await?; + +// Status information +println!("Status Code: {}", response.status_code); +println!("Status Message: {}", response.status_message); + +// Headers +for (name, value) in &response.headers { + println!("{}: {}", name, value); +} + +// Body as string +let text = response.text()?; + +// Body as bytes +let bytes = &response.body; + +// Parse JSON response +let json_data: serde_json::Value = serde_json::from_slice(&response.body)?; +``` + +### Status Code Checking + +```rust +if response.is_success() { + // 2xx status codes + println!("Request successful"); +} else if response.is_client_error() { + // 4xx status codes + println!("Client error: {}", response.status_message); +} else if response.is_server_error() { + // 5xx status codes + println!("Server error: {}", response.status_message); +} +``` + +## Protocol Implementation + +The GURT client automatically handles the complete GURT protocol: + +1. **TCP Connection**: Establishes initial connection to the server +2. **Handshake**: Sends `HANDSHAKE` request and waits for `101 Switching Protocols` +3. **TLS Upgrade**: Upgrades the connection to TLS 1.3 with GURT ALPN +4. **Request/Response**: Sends the actual HTTP-style request over encrypted connection + +All of this happens transparently when you call methods like `get()`, `post()`, etc. + +## URL Parsing + +The client automatically parses `gurt://` URLs: + +```rust +// These are all valid GURT URLs: +client.get("gurt://example.com/").await?; // Port 4878 (default) +client.get("gurt://example.com:8080/api").await?; // Custom port +client.get("gurt://192.168.1.100/test").await?; // IP address +client.get("gurt://localhost:4878/dev").await?; // Localhost +``` + +### URL Components + +The client extracts: +- **Host**: Domain name or IP address +- **Port**: Specified port or default (4878) +- **Path**: Request path (defaults to `/`) + +## Error Handling + +### Error Types + +```rust +use gurt::GurtError; + +match client.get("gurt://invalid-url").await { + Ok(response) => { + // Handle successful response + } + Err(GurtError::InvalidMessage(msg)) => { + println!("Invalid request: {}", msg); + } + Err(GurtError::Connection(msg)) => { + println!("Connection error: {}", msg); + } + Err(GurtError::Timeout(msg)) => { + println!("Request timeout: {}", msg); + } + Err(GurtError::Io(err)) => { + println!("IO error: {}", err); + } + Err(err) => { + println!("Other error: {}", err); + } +} +``` + +### Timeout Configuration + +```rust +let config = GurtClientConfig { + connect_timeout: Duration::from_secs(5), // Connection timeout + request_timeout: Duration::from_secs(30), // Overall request timeout + handshake_timeout: Duration::from_secs(3), // GURT handshake timeout + ..Default::default() +}; +``` + +## Why Rust-first? +Rust was chosen for the official GURT protocol implementation due to its embedded nature. + +To keep the core organized & not write identical code in GDScript, we used a GDExtension. A GDExtension can be created with a multitude of languages, but Rust was the one that provided the best performance, size, and programming ergonomics. + +We expect the community to implement bindings for other languages, such as Python and JavaScript, to make GURT accessible for everybody! + +## Example: Building a GURT API Client + +```rust +use gurt::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct CreateUser { + name: String, + email: String, +} + +#[derive(Deserialize)] +struct User { + id: u64, + name: String, + email: String, +} + +struct ApiClient { + client: GurtClient, + base_url: String, +} + +impl ApiClient { + fn new(base_url: String) -> Self { + Self { + client: GurtClient::new(), + base_url, + } + } + + async fn create_user(&self, user: CreateUser) -> Result { + let url = format!("{}/users", self.base_url); + let response = self.client.post_json(&url, &user).await?; + + if !response.is_success() { + return Err(GurtError::invalid_message( + format!("API error: {}", response.status_message) + )); + } + + let user: User = serde_json::from_slice(&response.body)?; + Ok(user) + } + + async fn get_user(&self, id: u64) -> Result { + let url = format!("{}/users/{}", self.base_url, id); + let response = self.client.get(&url).await?; + + if response.status_code == 404 { + return Err(GurtError::invalid_message("User not found".to_string())); + } + + if !response.is_success() { + return Err(GurtError::invalid_message( + format!("API error: {}", response.status_message) + )); + } + + let user: User = serde_json::from_slice(&response.body)?; + Ok(user) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let api = ApiClient::new("gurt://api.example.com".to_string()); + + // Create a user + let new_user = CreateUser { + name: "Alice".to_string(), + email: "alice@example.com".to_string(), + }; + + let user = api.create_user(new_user).await?; + println!("Created user: {} (ID: {})", user.name, user.id); + + // Retrieve the user + let retrieved_user = api.get_user(user.id).await?; + println!("Retrieved user: {}", retrieved_user.name); + + Ok(()) +} +``` diff --git a/docs/docs/gurt-protocol.md b/docs/docs/gurt-protocol.md new file mode 100644 index 0000000..8c2942b --- /dev/null +++ b/docs/docs/gurt-protocol.md @@ -0,0 +1,272 @@ +--- +sidebar_position: 2 +--- + +# GURT Protocol + +**GURT** (version 1.0.0) is a TCP-based application protocol designed as an HTTP-like alternative with built-in TLS 1.3 encryption. It serves as the foundation for the Gurted ecosystem, enabling secure communication between clients and servers using the `gurt://` URL scheme. + +## Overview + +GURT provides a familiar HTTP-like syntax while offering security through mandatory TLS 1.3 encryption. Unlike HTTP where encryption is optional (HTTPS), all GURT connections are encrypted by default. + +### Key Features + +- **HTTP-like syntax** with familiar methods (GET, POST, PUT, DELETE, etc.) +- **Built-in required TLS 1.3 encryption** for all connections +- **Binary and text data support** +- **Status codes** compatible with HTTP semantics +- **Default port**: 4878 +- **ALPN identifier**: `GURT/1.0` + +## URL Scheme + +GURT uses the `gurt://` URL scheme: + +``` +gurt://example.com/path +gurt://192.168.1.100:4878/api/data +gurt://localhost:4878/hello +``` + +The protocol automatically defaults to port 4878. + +## Communication Flow + +Every GURT session must begin with a `HANDSHAKE` request: + +```http +HANDSHAKE / GURT/1.0.0\r\n +host: example.com\r\n +user-agent: GURT-Client/1.0.0\r\n +\r\n +``` + +Server responds with protocol confirmation: + +```http +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 +``` + +## Message Format + +### Request Structure + +```http +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] +``` + +**Components:** +- **Method line**: `METHOD /path GURT/1.0.0` +- **Headers**: Lowercase names, colon-separated values +- **Header terminator**: `\r\n\r\n` +- **Body**: Optional message content + +### Response Structure + +```http +GURT/1.0.0 200 OK\r\n +content-type: application/json\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 +[response body] +``` + +**Components:** +- **Status line**: `GURT/1.0.0 ` +- **Headers**: Lowercase names, required for responses +- **Body**: Optional response content + +## HTTP Methods + +GURT supports all standard HTTP methods: + +| Method | Purpose | Body Allowed | +|--------|---------|--------------| +| `GET` | Retrieve resource | No | +| `POST` | Create/submit data | Yes | +| `PUT` | Update/replace resource | Yes | +| `DELETE` | Remove resource | No | +| `HEAD` | Get headers only | No | +| `OPTIONS` | Get allowed methods | No | +| `PATCH` | Partial update | Yes | +| `HANDSHAKE` | Protocol handshake | No | + +## Status Codes + +GURT uses HTTP-compatible status codes: + +### Success (2xx) +- `200 OK` - Request successful +- `201 CREATED` - Resource created +- `202 ACCEPTED` - Request accepted for processing +- `204 NO_CONTENT` - Success with no response body + +### Protocol (1xx) +- `101 SWITCHING_PROTOCOLS` - Handshake successful + +### Client Error (4xx) +- `400 BAD_REQUEST` - Invalid request format +- `401 UNAUTHORIZED` - Authentication required +- `403 FORBIDDEN` - Access denied +- `404 NOT_FOUND` - Resource not found +- `405 METHOD_NOT_ALLOWED` - Method not supported +- `408 TIMEOUT` - Request timeout +- `413 TOO_LARGE` - Request too large +- `415 UNSUPPORTED_MEDIA_TYPE` - Unsupported content type + +### Server Error (5xx) +- `500 INTERNAL_SERVER_ERROR` - Server error +- `501 NOT_IMPLEMENTED` - Method not implemented +- `502 BAD_GATEWAY` - Gateway error +- `503 SERVICE_UNAVAILABLE` - Service unavailable +- `504 GATEWAY_TIMEOUT` - Gateway timeout + +## Security + +All connections must use TLS 1.3 for encryption. This means you have to generate and use a valid TLS certificate for your GURT 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 + gurty serve --cert localhost+2.pem --key localhost+2-key.pem + ``` +Install Gurty, the official GURT server tool, [on the Gurted.com download page](https://gurted.com/download/) + +## Protocol Limits + +| Parameter | Limit | +|-----------|-------| +| Maximum message size | 10 MB | +| Default connection timeout | 10 seconds | +| Default request timeout | 30 seconds | +| Default handshake timeout | 5 seconds | +| Maximum connection pool size | 10 connections | +| Pool idle timeout | 300 seconds | + +## Example Session + +Complete GURT communication example: + +```http +# Client connects and sends handshake +HANDSHAKE / GURT/1.0.0\r\n +host: example.com\r\n +user-agent: GURT-Client/1.0.0\r\n +\r\n + +# Server confirms protocol +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, 14 Aug 2025 12:00:00 GMT\r\n +\r\n + +# All further communication is encrypted +# Client sends JSON data +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 responds with JSON +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, 14 Aug 2025 12:00:01 GMT\r\n +\r\n +{"result":"ok"} +``` + +## Domain Resolution + +GURT integrates with Gurted's custom DNS system: + +### Direct IP Access +``` +gurt://192.168.1.100:4878/ +gurt://localhost:4878/api +``` + +### Domain Resolution +``` +gurt://example.real/ # Resolves via Gurted DNS +``` + +The Gurted DNS server resolves domains in the format `name.tld` to IP addresses, enabling human-readable domain names for GURT services. This is done automatically by your GURT browser and is documented in the [DNS System documentation](./dns-system.md). + +## Implementation + +GURT is implemented in Rust with the following components: + +- **Protocol Library**: Core protocol implementation, reusable as a Rust crate +- **CLI Tool (Gurty)**: Server setup and management +- **Godot Extension**: Browser integration for Flumi diff --git a/docs/docs/gurt-server.md b/docs/docs/gurt-server.md new file mode 100644 index 0000000..a081151 --- /dev/null +++ b/docs/docs/gurt-server.md @@ -0,0 +1,471 @@ +--- +sidebar_position: 4 +--- + +# GURT Server Library + +The GURT server library provides a framework for building HTTP-like servers that use the GURT protocol. It features automatic TLS handling, route-based request handling, and middleware support. + +## Installation + +Add the GURT library to your `Cargo.toml`: + +```toml +[dependencies] +gurt = "0.1" +tokio = { version = "1.0", features = ["full"] } +tracing = "0.1" +tracing-subscriber = "0.3" +serde_json = "1.0" +``` + +## Quick Start + +```rust +use gurt::prelude::*; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let server = GurtServer::with_tls_certificates("cert.pem", "key.pem")? + .get("/", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("

Hello, GURT!

")) + }) + .get("/api/users", |_ctx| async { + let users = json!(["Alice", "Bob"]); + Ok(GurtResponse::ok().with_json_body(&users)) + }); + + println!("GURT server starting on gurt://127.0.0.1:4878"); + server.listen("127.0.0.1:4878").await +} +``` + +## Creating a Server + +### Basic Server + +```rust +let server = GurtServer::new(); +``` + +### Server with TLS Certificates + +```rust +// Load TLS certificates during server creation +let server = GurtServer::with_tls_certificates("cert.pem", "key.pem")?; + +// Or load certificates later +let mut server = GurtServer::new(); +server.load_tls_certificates("cert.pem", "key.pem")?; +``` + +## Route Handlers + +### Method-Specific Routes + +```rust +let server = GurtServer::with_tls_certificates("cert.pem", "key.pem")? + .get("/", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("GET request")) + }) + .post("/submit", |ctx| async { + let body = ctx.text()?; + println!("Received: {}", body); + Ok(GurtResponse::new(GurtStatusCode::Created).with_string_body("Created")) + }) + .put("/update", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("Updated")) + }) + .delete("/delete", |_ctx| async { + Ok(GurtResponse::new(GurtStatusCode::NoContent)) + }) + .patch("/partial", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("Patched")) + }); +``` + +### Any Method Route + +```rust +let server = server.any("/webhook", |ctx| async { + match ctx.method() { + GurtMethod::GET => Ok(GurtResponse::ok().with_string_body("GET webhook")), + GurtMethod::POST => Ok(GurtResponse::ok().with_string_body("POST webhook")), + _ => Ok(GurtResponse::new(GurtStatusCode::MethodNotAllowed)), + } +}); +``` + +### Route Patterns + +```rust +let server = server + .get("/users", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("All users")) + }) + .get("/users/*", |ctx| async { + // Matches /users/123, /users/profile, etc. + let path = ctx.path(); + Ok(GurtResponse::ok().with_string_body(format!("User path: {}", path))) + }) + .get("/api/*", |_ctx| async { + // Matches any path starting with /api/ + Ok(GurtResponse::ok().with_string_body("API endpoint")) + }); +``` + +## Server Context + +The `ServerContext` provides access to request information: + +```rust +.post("/analyze", |ctx| async { + // Client information + println!("Client IP: {}", ctx.client_ip()); + println!("Client Port: {}", ctx.client_port()); + + // Request details + println!("Method: {:?}", ctx.method()); + println!("Path: {}", ctx.path()); + + // Headers + if let Some(content_type) = ctx.header("content-type") { + println!("Content-Type: {}", content_type); + } + + // Iterate all headers + for (name, value) in ctx.headers() { + println!("{}: {}", name, value); + } + + // Body data + let body_bytes = ctx.body(); + let body_text = ctx.text()?; + + Ok(GurtResponse::ok().with_string_body("Analyzed")) +}) +``` + +## Response Building + +### Basic Responses + +```rust +// Success responses +GurtResponse::ok() // 200 OK +GurtResponse::new(GurtStatusCode::Created) // 201 Created +GurtResponse::new(GurtStatusCode::Accepted) // 202 Accepted +GurtResponse::new(GurtStatusCode::NoContent) // 204 No Content + +// Client error responses +GurtResponse::bad_request() // 400 Bad Request +GurtResponse::new(GurtStatusCode::Unauthorized) // 401 Unauthorized +GurtResponse::new(GurtStatusCode::Forbidden) // 403 Forbidden +GurtResponse::not_found() // 404 Not Found +GurtResponse::new(GurtStatusCode::MethodNotAllowed) // 405 Method Not Allowed + +// Server error responses +GurtResponse::internal_server_error() // 500 Internal Server Error +GurtResponse::new(GurtStatusCode::NotImplemented) // 501 Not Implemented +GurtResponse::new(GurtStatusCode::ServiceUnavailable) // 503 Service Unavailable +``` + +### Response with Body + +```rust +// String body +GurtResponse::ok().with_string_body("Hello, World!") + +// JSON body +use serde_json::json; +let data = json!({"message": "Hello", "status": "success"}); +GurtResponse::ok().with_json_body(&data) + +// Binary body +let image_data = std::fs::read("image.png")?; +GurtResponse::ok() + .with_header("content-type", "image/png") + .with_body(image_data) +``` + +### Response with Headers + +```rust +GurtResponse::ok() + .with_string_body("Custom response") + .with_header("x-custom-header", "custom-value") + .with_header("cache-control", "no-cache") + .with_header("content-type", "text/plain; charset=utf-8") +``` + +## Advanced Examples + +### JSON API Server + +```rust +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize, Deserialize)] +struct User { + id: u64, + name: String, + email: String, +} + +let server = GurtServer::with_tls_certificates("cert.pem", "key.pem")? + .get("/api/users", |ctx| async { + let users = vec![ + User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string() }, + User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string() }, + ]; + + Ok(GurtResponse::ok() + .with_header("content-type", "application/json") + .with_json_body(&users)) + }) + .post("/api/users", |ctx| async { + let body = ctx.text()?; + let user: User = serde_json::from_str(&body) + .map_err(|_| GurtError::invalid_message("Invalid JSON"))?; + + // Save user to database here... + println!("Creating user: {}", user.name); + + Ok(GurtResponse::new(GurtStatusCode::Created) + .with_header("content-type", "application/json") + .with_json_body(&user)?) + }) + .get("/api/users/*", |ctx| async { + let path = ctx.path(); + if let Some(user_id) = path.strip_prefix("/api/users/") { + if let Ok(id) = user_id.parse::() { + // Get user from database here... + let user = User { + id, + name: format!("User {}", id), + email: format!("user{}@example.com", id), + }; + + Ok(GurtResponse::ok() + .with_header("content-type", "application/json") + .with_json_body(&user)) + } else { + Ok(GurtResponse::bad_request() + .with_string_body("Invalid user ID")) + } + } else { + Ok(GurtResponse::not_found()) + } + }); +``` + +### File Server + +```rust +use std::path::Path; +use tokio::fs; + +let server = server.get("/files/*", |ctx| async { + let path = ctx.path(); + let file_path = path.strip_prefix("/files/").unwrap_or(""); + + // Security: prevent directory traversal + if file_path.contains("..") { + return Ok(GurtResponse::new(GurtStatusCode::Forbidden) + .with_string_body("Access denied")); + } + + let full_path = format!("./static/{}", file_path); + + match fs::read(&full_path).await { + Ok(data) => { + let content_type = match Path::new(&full_path).extension() + .and_then(|ext| ext.to_str()) { + Some("html") => "text/html", + Some("css") => "text/css", + Some("js") => "application/javascript", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + _ => "application/octet-stream", + }; + + Ok(GurtResponse::ok() + .with_header("content-type", content_type) + .with_body(data)) + } + Err(_) => { + Ok(GurtResponse::not_found() + .with_string_body("File not found")) + } + } +}); +``` + +### Middleware Pattern + +```rust +// Request logging middleware +async fn log_request(ctx: &ServerContext) -> Result<()> { + println!("{} {} from {}", + ctx.method(), + ctx.path(), + ctx.client_ip() + ); + Ok(()) +} + +// Authentication middleware +async fn require_auth(ctx: &ServerContext) -> Result<()> { + if let Some(auth_header) = ctx.header("authorization") { + if auth_header.starts_with("Bearer ") { + // Validate token here... + return Ok(()); + } + } + Err(GurtError::invalid_message("Authentication required")) +} + +let server = server + .get("/protected", |ctx| async { + // Apply middleware + log_request(ctx).await?; + require_auth(ctx).await?; + + Ok(GurtResponse::ok() + .with_string_body("Protected content")) + }) + .post("/api/data", |ctx| async { + log_request(ctx).await?; + + // Handle request + Ok(GurtResponse::ok() + .with_string_body("Data processed")) + }); +``` + +### Error Handling + +```rust +let server = server.post("/api/process", |ctx| async { + match process_data(ctx).await { + Ok(result) => { + Ok(GurtResponse::ok() + .with_json_body(&result)) + } + Err(ProcessError::ValidationError(msg)) => { + Ok(GurtResponse::bad_request() + .with_json_body(&json!({"error": msg}))) + } + Err(ProcessError::NotFound) => { + Ok(GurtResponse::not_found() + .with_json_body(&json!({"error": "Resource not found"}))) + } + Err(_) => { + Ok(GurtResponse::internal_server_error() + .with_json_body(&json!({"error": "Internal server error"}))) + } + } +}); + +async fn process_data(ctx: &ServerContext) -> Result { + // Your processing logic here + todo!() +} + +#[derive(Debug)] +enum ProcessError { + ValidationError(String), + NotFound, + InternalError, +} +``` + +## TLS Configuration + +### Development Certificates + +For development, use `mkcert` to generate trusted local certificates: + +```bash +# Install mkcert +choco install mkcert # Windows +brew install mkcert # macOS +# or download from GitHub releases + +# Install local CA +mkcert -install + +# Generate certificates +mkcert localhost 127.0.0.1 ::1 +``` + +### Production Certificates + +For production, generate certificates with OpenSSL: + +```bash +# Generate private key +openssl genpkey -algorithm RSA -out server.key -pkcs8 + +# Generate certificate signing request +openssl req -new -key server.key -out server.csr + +# Generate self-signed certificate +openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt + +# Or in one step +openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes +``` + +## Listening and Deployment + +```rust +// Listen on all interfaces +server.listen("0.0.0.0:4878").await?; + +// Listen on specific interface +server.listen("127.0.0.1:8080").await?; + +// Listen on IPv6 +server.listen("[::1]:4878").await?; +``` + +## Testing + +```rust +#[cfg(test)] +mod tests { + use super::*; + use gurt::GurtClient; + + #[tokio::test] + async fn test_server() { + let server = GurtServer::with_tls_certificates("test-cert.pem", "test-key.pem") + .unwrap() + .get("/test", |_ctx| async { + Ok(GurtResponse::ok().with_string_body("test response")) + }); + + // Start server in background + tokio::spawn(async move { + server.listen("127.0.0.1:9999").await.unwrap(); + }); + + // Give server time to start + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Test with client + let client = GurtClient::new(); + let response = client.get("gurt://127.0.0.1:9999/test").await.unwrap(); + + assert_eq!(response.status_code, 200); + assert_eq!(response.text().unwrap(), "test response"); + } +} +``` diff --git a/docs/docs/gurty-cli.md b/docs/docs/gurty-cli.md new file mode 100644 index 0000000..a97f034 --- /dev/null +++ b/docs/docs/gurty-cli.md @@ -0,0 +1,105 @@ +--- +sidebar_position: 5 +--- + +# Gurty CLI Tool + +**Gurty** is a command-line interface tool for setting up and managing GURT protocol servers. It provides an easy way to deploy GURT servers with proper TLS configuration for both development and production environments. + +## Installation + +Build Gurty from the protocol CLI directory: + +```bash +cd protocol/cli +cargo build --release +``` + +The binary will be available at `target/release/gurty` (or `gurty.exe` on Windows). + +## Quick Start + +### Development Setup + +1. **Install mkcert** for development certificates: + ```bash + # Windows (with Chocolatey) + choco install mkcert + + # macOS (with Homebrew) + brew install mkcert + + # Or download from: https://github.com/FiloSottile/mkcert/releases + ``` + +2. **Install local CA** in your system: + ```bash + mkcert -install + ``` + +3. **Generate localhost certificates**: + ```bash + cd 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**: + ```bash + cargo run --release serve --cert localhost+2.pem --key localhost+2-key.pem + ``` + +### Production Setup + +1. **Generate production certificates** with OpenSSL: + ```bash + # Generate private key + openssl genpkey -algorithm RSA -out gurt-server.key -pkcs8 + + # 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 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 + ``` + +## Commands + +### `serve` Command + +Start a GURT server with TLS certificates. + +```bash +gurty serve [OPTIONS] +``` + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--cert ` | Path to TLS certificate file | Required | +| `--key ` | Path to TLS private key file | Required | +| `--host ` | Host address to bind to | `127.0.0.1` | +| `--port ` | Port number to listen on | `4878` | +| `--dir ` | Directory to serve files from | None | +| `--log-level ` | Logging level (error, warn, info, debug, trace) | `info` | + +#### Examples + +```bash +gurty serve --cert localhost+2.pem --key localhost+2-key.pem --dir ./public +``` +Debug: +```bash +gurty serve --cert dev.pem --key dev-key.pem --log-level debug +``` diff --git a/docs/docs/intro.md b/docs/docs/intro.md index a954e7c..91cd61b 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -15,6 +15,8 @@ sidebar_position: 1 **GURT** is a *content delivery protocol* similar to HTTPS. It's the core of how Gurted applications communicate. +Learn more about the GURT protocol: [Protocol Specification](./gurt-protocol.md) + ## Getting Started Get started by **exploring Gurted sites** or **try creating your first GURT page**. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index c2fe598..b09c51c 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -137,7 +137,7 @@ const config: Config = { prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, - additionalLanguages: ['lua'], + additionalLanguages: ['lua', 'bash', 'http'], }, } satisfies Preset.ThemeConfig, }; diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 2897139..0e5a52c 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -13,21 +13,38 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; Create as many sidebars as you want. */ const sidebars: SidebarsConfig = { - // By default, Docusaurus generates a sidebar from the docs folder structure - tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], - - // But you can create a sidebar manually - /* tutorialSidebar: [ 'intro', - 'hello', { type: 'category', - label: 'Tutorial', - items: ['tutorial-basics/create-a-document'], + label: 'GURT Protocol', + items: [ + 'gurt-protocol', + 'gurt-client', + 'gurt-server', + 'gurty-cli', + ], + }, + { + type: 'category', + label: 'Ecosystem', + items: [ + 'dns-system', + 'flumi-browser', + ], + }, + { + type: 'category', + label: 'Web Standards', + items: [ + 'html', + 'css', + ], }, ], - */ + + // Auto-generated fallback + // tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], }; export default sidebars; diff --git a/flumi/Scripts/B9/HTMLParser.gd b/flumi/Scripts/B9/HTMLParser.gd index c8043fa..2b54a84 100644 --- a/flumi/Scripts/B9/HTMLParser.gd +++ b/flumi/Scripts/B9/HTMLParser.gd @@ -377,7 +377,7 @@ func apply_element_styles(node: Control, element: HTMLElement, parser: HTMLParse var text = HTMLParser.get_bbcode_with_styles(element, styles, parser) label.text = text -static func apply_element_bbcode_formatting(element: HTMLElement, styles: Dictionary, content: String) -> String: +static func apply_element_bbcode_formatting(element: HTMLElement, styles: Dictionary, content: String, parser: HTMLParser = null) -> String: match element.tag_name: "b": if styles.has("font-bold") and styles["font-bold"]: @@ -416,6 +416,7 @@ static func apply_element_bbcode_formatting(element: HTMLElement, styles: Dictio else: color = str(c) if href.length() > 0: + # Pass raw href - URL resolution happens in handle_link_click return "[color=%s][url=%s]%s[/url][/color]" % [color, href, content] return content @@ -429,10 +430,10 @@ static func get_bbcode_with_styles(element: HTMLElement, styles: Dictionary, par if parser != null: child_styles = parser.get_element_styles_with_inheritance(child, "", []) var child_content = HTMLParser.get_bbcode_with_styles(child, child_styles, parser) - child_content = apply_element_bbcode_formatting(child, child_styles, child_content) + child_content = apply_element_bbcode_formatting(child, child_styles, child_content, parser) text += child_content # Apply formatting to the current element itself - text = apply_element_bbcode_formatting(element, styles, text) + text = apply_element_bbcode_formatting(element, styles, text, parser) return text diff --git a/flumi/Scripts/GurtProtocol.gd b/flumi/Scripts/GurtProtocol.gd index 99ae3af..94312c5 100644 --- a/flumi/Scripts/GurtProtocol.gd +++ b/flumi/Scripts/GurtProtocol.gd @@ -218,10 +218,10 @@ static func get_error_type(error_message: String) -> Dictionary: else: return {"code": "ERR_UNKNOWN", "title": "Something went wrong", "icon": "❌"} -static func create_error_page(error_message: String) -> String: +static func create_error_page(error_message: String) -> PackedByteArray: var error_info = get_error_type(error_message) - return """ + return (""" """ + error_info.title + """ - GURT -

Welcome to the GURT Protocol!

-

This server is successfully running. We couldn't find index.html though :(

-

Protocol: GURT/{}

-

Client IP: {}

- - - "#, - gurt::GURT_VERSION, - client_ip, - ))) +

Directory Listing

+
+"#); + 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 { "style=\"dir\"" } else { "" }; + + listing.push_str(&format!( + r#" {}"#, + class, name, display_name + )); + listing.push('\n'); + } + + listing.push_str("
\n"); + + 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")) + } + } } } }) @@ -214,7 +231,6 @@ fn create_file_server(base_dir: PathBuf, cert_path: Option, key_path: O Directory Listing @@ -228,10 +244,10 @@ fn create_file_server(base_dir: PathBuf, cert_path: Option, key_path: O 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" }; + let class = if is_dir { "style=\"dir\"" } else { "" }; listing.push_str(&format!( - r#" {}"#, + r#" {}"#, class, name, display_name )); listing.push('\n'); diff --git a/protocol/gdextension/Cargo.toml b/protocol/gdextension/Cargo.toml index 967a2c3..62e97c6 100644 --- a/protocol/gdextension/Cargo.toml +++ b/protocol/gdextension/Cargo.toml @@ -16,17 +16,7 @@ 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" +tokio = { version = "1.0", features = ["rt"] } url = "2.5" [profile.release] diff --git a/protocol/gdextension/src/lib.rs b/protocol/gdextension/src/lib.rs index b521749..9e31680 100644 --- a/protocol/gdextension/src/lib.rs +++ b/protocol/gdextension/src/lib.rs @@ -1,11 +1,9 @@ use godot::prelude::*; use gurt::prelude::*; -use gurt::{GurtMethod, GurtRequest}; +use gurt::{GurtMethod, GurtClientConfig}; use tokio::runtime::Runtime; use std::sync::Arc; use std::cell::RefCell; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; struct GurtGodotExtension; @@ -109,7 +107,7 @@ impl GurtProtocolClient { } }; - let mut config = ClientConfig::default(); + let mut config = GurtClientConfig::default(); config.request_timeout = tokio::time::Duration::from_secs(timeout_seconds as u64); let client = GurtClient::with_config(config); @@ -168,7 +166,31 @@ impl GurtProtocolClient { } }; - let response = match runtime.block_on(self.gurt_request_with_handshake(host, port, method, path)) { + let client_binding = self.client.borrow(); + let client = match client_binding.as_ref() { + Some(c) => c, + None => { + godot_print!("No client available"); + return None; + } + }; + + let url = format!("gurt://{}:{}{}", host, port, path); + let response = match runtime.block_on(async { + match method { + GurtMethod::GET => client.get(&url).await, + GurtMethod::POST => client.post(&url, "").await, + GurtMethod::PUT => client.put(&url, "").await, + GurtMethod::DELETE => client.delete(&url).await, + GurtMethod::HEAD => client.head(&url).await, + GurtMethod::OPTIONS => client.options(&url).await, + GurtMethod::PATCH => client.patch(&url, "").await, + _ => { + godot_print!("Unsupported method: {:?}", method); + return Err(GurtError::invalid_message("Unsupported method")); + } + } + }) { Ok(resp) => resp, Err(e) => { godot_print!("GURT request failed: {}", e); @@ -179,145 +201,6 @@ impl GurtProtocolClient { Some(self.convert_response(response)) } - async fn gurt_request_with_handshake(&self, host: &str, port: u16, method: GurtMethod, path: &str) -> gurt::Result { - 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::().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> { - 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; diff --git a/protocol/library/Cargo.lock b/protocol/library/Cargo.lock index e948075..449666a 100644 --- a/protocol/library/Cargo.lock +++ b/protocol/library/Cargo.lock @@ -224,6 +224,16 @@ dependencies = [ "cc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -365,6 +375,7 @@ dependencies = [ "base64", "chrono", "rustls", + "rustls-native-certs", "rustls-pemfile", "serde", "serde_json", @@ -686,6 +697,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "overload" version = "0.1.1" @@ -830,6 +847,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -872,6 +901,38 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "security-framework" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" diff --git a/protocol/library/Cargo.toml b/protocol/library/Cargo.toml index 6d36aa4..1b61ca7 100644 --- a/protocol/library/Cargo.toml +++ b/protocol/library/Cargo.toml @@ -30,6 +30,7 @@ chrono = { version = "0.4", features = ["serde"] } tokio-rustls = "0.26" rustls = "0.23" rustls-pemfile = "2.0" +rustls-native-certs = "0.8" base64 = "0.22" url = "2.5" diff --git a/protocol/library/src/client.rs b/protocol/library/src/client.rs index 3410195..73a566d 100644 --- a/protocol/library/src/client.rs +++ b/protocol/library/src/client.rs @@ -2,15 +2,18 @@ use crate::{ GurtError, Result, GurtRequest, GurtResponse, protocol::{DEFAULT_PORT, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, DEFAULT_HANDSHAKE_TIMEOUT, BODY_SEPARATOR}, message::GurtMethod, + crypto::GURT_ALPN, }; use tokio::net::TcpStream; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::{timeout, Duration}; +use tokio_rustls::{TlsConnector, rustls::{ClientConfig as TlsClientConfig, RootCertStore, pki_types::ServerName}}; +use std::sync::Arc; use url::Url; use tracing::debug; #[derive(Debug, Clone)] -pub struct ClientConfig { +pub struct GurtClientConfig { pub connect_timeout: Duration, pub request_timeout: Duration, pub handshake_timeout: Duration, @@ -18,7 +21,7 @@ pub struct ClientConfig { pub max_redirects: usize, } -impl Default for ClientConfig { +impl Default for GurtClientConfig { fn default() -> Self { Self { connect_timeout: Duration::from_secs(DEFAULT_CONNECTION_TIMEOUT), @@ -30,29 +33,55 @@ impl Default for ClientConfig { } } +#[derive(Debug)] +enum Connection { + Plain(TcpStream), + Tls(tokio_rustls::client::TlsStream), +} + +impl Connection { + async fn read(&mut self, buf: &mut [u8]) -> Result { + match self { + Connection::Plain(stream) => stream.read(buf).await.map_err(|e| GurtError::connection(e.to_string())), + Connection::Tls(stream) => stream.read(buf).await.map_err(|e| GurtError::connection(e.to_string())), + } + } + + async fn write_all(&mut self, buf: &[u8]) -> Result<()> { + match self { + Connection::Plain(stream) => stream.write_all(buf).await.map_err(|e| GurtError::connection(e.to_string())), + Connection::Tls(stream) => stream.write_all(buf).await.map_err(|e| GurtError::connection(e.to_string())), + } + } +} + #[derive(Debug)] struct PooledConnection { - stream: TcpStream, + connection: Connection, } impl PooledConnection { fn new(stream: TcpStream) -> Self { - Self { stream } + Self { connection: Connection::Plain(stream) } + } + + fn with_tls(stream: tokio_rustls::client::TlsStream) -> Self { + Self { connection: Connection::Tls(stream) } } } pub struct GurtClient { - config: ClientConfig, + config: GurtClientConfig, } impl GurtClient { pub fn new() -> Self { Self { - config: ClientConfig::default(), + config: GurtClientConfig::default(), } } - pub fn with_config(config: ClientConfig) -> Self { + pub fn with_config(config: GurtClientConfig) -> Self { Self { config, } @@ -71,7 +100,7 @@ impl GurtClient { Ok(conn) } - async fn read_response_data(&self, stream: &mut TcpStream) -> Result> { + async fn read_response_data(&self, conn: &mut PooledConnection) -> Result> { let mut buffer = Vec::new(); let mut temp_buffer = [0u8; 8192]; @@ -82,7 +111,7 @@ impl GurtClient { return Err(GurtError::timeout("Response timeout")); } - let bytes_read = stream.read(&mut temp_buffer).await?; + let bytes_read = conn.connection.read(&mut temp_buffer).await?; if bytes_read == 0 { break; // Connection closed } @@ -106,17 +135,92 @@ impl GurtClient { } } + async fn perform_handshake(&self, host: &str, port: u16) -> Result> { + debug!("Starting GURT handshake with {}:{}", host, port); + + let mut plain_conn = self.create_connection(host, port).await?; + + let handshake_request = GurtRequest::new(GurtMethod::HANDSHAKE, "/".to_string()) + .with_header("Host", host) + .with_header("User-Agent", &self.config.user_agent); + + let handshake_data = handshake_request.to_string(); + plain_conn.connection.write_all(handshake_data.as_bytes()).await?; + + let handshake_response_bytes = timeout( + self.config.handshake_timeout, + self.read_response_data(&mut plain_conn) + ).await + .map_err(|_| GurtError::timeout("Handshake timeout"))??; + + let handshake_response = GurtResponse::parse_bytes(&handshake_response_bytes)?; + + if handshake_response.status_code != 101 { + return Err(GurtError::protocol(format!("Handshake failed: {} {}", + handshake_response.status_code, + handshake_response.status_message))); + } + + let tcp_stream = match plain_conn.connection { + Connection::Plain(stream) => stream, + _ => return Err(GurtError::protocol("Expected plain connection for handshake")), + }; + + self.upgrade_to_tls(tcp_stream, host).await + } + + async fn upgrade_to_tls(&self, stream: TcpStream, host: &str) -> Result> { + debug!("Upgrading connection to TLS for {}", host); + + let mut root_store = RootCertStore::empty(); + + let cert_result = rustls_native_certs::load_native_certs(); + let mut added = 0; + for cert in cert_result.certs { + if root_store.add(cert).is_ok() { + added += 1; + } + } + if added == 0 { + return Err(GurtError::crypto("No valid system certificates found".to_string())); + } + + let mut client_config = TlsClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + client_config.alpn_protocols = vec![GURT_ALPN.to_vec()]; + + let connector = TlsConnector::from(Arc::new(client_config)); + + let server_name = match host { + "127.0.0.1" => "localhost", + "localhost" => "localhost", + _ => host + }; + + let domain = ServerName::try_from(server_name.to_string()) + .map_err(|e| GurtError::crypto(format!("Invalid server name '{}': {}", server_name, e)))?; + + let tls_stream = connector.connect(domain, stream).await + .map_err(|e| GurtError::crypto(format!("TLS handshake failed: {}", e)))?; + + debug!("TLS connection established with {}", host); + Ok(tls_stream) + } + async fn send_request_internal(&self, host: &str, port: u16, request: GurtRequest) -> Result { debug!("Sending {} {} to {}:{}", request.method, request.path, host, port); - let mut conn = self.create_connection(host, port).await?; + let tls_stream = self.perform_handshake(host, port).await?; + let mut conn = PooledConnection::with_tls(tls_stream); let request_data = request.to_string(); - conn.stream.write_all(request_data.as_bytes()).await?; + conn.connection.write_all(request_data.as_bytes()).await?; let response_bytes = timeout( self.config.request_timeout, - self.read_response_data(&mut conn.stream) + self.read_response_data(&mut conn) ).await .map_err(|_| GurtError::timeout("Request timeout"))??; diff --git a/protocol/library/src/lib.rs b/protocol/library/src/lib.rs index 08ed0cc..62f3714 100644 --- a/protocol/library/src/lib.rs +++ b/protocol/library/src/lib.rs @@ -10,7 +10,7 @@ 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 use client::{GurtClient, GurtClientConfig}; pub mod prelude { pub use crate::{ @@ -19,6 +19,6 @@ pub mod prelude { GURT_VERSION, DEFAULT_PORT, CryptoManager, TlsConfig, GURT_ALPN, TLS_VERSION, GurtServer, GurtHandler, ServerContext, Route, - GurtClient, ClientConfig, + GurtClient, GurtClientConfig, }; } \ No newline at end of file diff --git a/protocol/library/src/message.rs b/protocol/library/src/message.rs index b8e2db6..4ebf0e0 100644 --- a/protocol/library/src/message.rs +++ b/protocol/library/src/message.rs @@ -90,7 +90,7 @@ impl GurtRequest { self.headers.get(&key.to_lowercase()) } - pub fn body_as_string(&self) -> Result { + pub fn text(&self) -> Result { std::str::from_utf8(&self.body) .map(|s| s.to_string()) .map_err(|e| GurtError::invalid_message(format!("Invalid UTF-8 body: {}", e))) @@ -283,7 +283,7 @@ impl GurtResponse { self.headers.get(&key.to_lowercase()) } - pub fn body_as_string(&self) -> Result { + pub fn text(&self) -> Result { std::str::from_utf8(&self.body) .map(|s| s.to_owned()) .map_err(|e| GurtError::invalid_message(format!("Invalid UTF-8 body: {}", e))) @@ -524,7 +524,7 @@ mod tests { 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"); + assert_eq!(request.text().unwrap(), "test body"); } #[test] @@ -536,7 +536,7 @@ mod tests { 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(), ""); + assert_eq!(response.text().unwrap(), ""); } #[test] diff --git a/protocol/library/src/server.rs b/protocol/library/src/server.rs index f49e30d..f5f7182 100644 --- a/protocol/library/src/server.rs +++ b/protocol/library/src/server.rs @@ -47,8 +47,8 @@ impl ServerContext { &self.request.body } - pub fn body_as_string(&self) -> Result { - self.request.body_as_string() + pub fn text(&self) -> Result { + self.request.text() } pub fn header(&self, key: &str) -> Option<&String> { @@ -387,7 +387,7 @@ impl GurtServer { 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")); + .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?;