diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ffc901 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.idea +vendor +*.md +.dockerignore +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94b3d2e --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Database Configuration +DB_HOST=mariadb +DB_PORT=3306 +DB_USER=appwrite +DB_PASS=password +DB_NAME=appwrite + +# Redis Configuration +REDIS_HOST=redis +REDIS_PORT=6379 + +# Compute API Configuration +COMPUTE_API_URL=http://appwrite-api/v1/compute +COMPUTE_API_KEY= + +# MySQL Root Password (for docker-compose) +MYSQL_ROOT_PASSWORD=rootpassword + +# TLS Configuration (for TCP proxy) +PROXY_TLS_ENABLED=false +PROXY_TLS_CERT= +PROXY_TLS_KEY= +PROXY_TLS_CA= +PROXY_TLS_REQUIRE_CLIENT_CERT=false diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..c8e4809 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,26 @@ +name: Integration Tests + +on: + pull_request: + +jobs: + integration: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis, sockets + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run integration tests + run: composer test:integration diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..48f2eb9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + pull_request: + +jobs: + pint: + name: Laravel Pint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run Pint + run: composer lint diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..905f39f --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,26 @@ +name: Static Analysis + +on: + pull_request: + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPStan + run: composer check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3a16ce8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + pull_request: + +jobs: + unit: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis, sockets + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: composer test diff --git a/.gitignore b/.gitignore index 90abc6a..3a13f04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ /vendor/ /composer.lock /.phpunit.cache +/.phpunit.result.cache /.php-cs-fixer.cache /phpstan.neon /.idea/ .DS_Store *.log /coverage/ + +# Environment files +.env + +# Docker volumes +/docker-volumes/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..622d1a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM php:8.4.18-cli-alpine3.23 + +RUN apk update && apk upgrade && apk add --no-cache \ + autoconf \ + g++ \ + make \ + linux-headers \ + libstdc++ \ + brotli-dev \ + libzip-dev \ + openssl-dev \ + && rm -rf /var/cache/apk/* + +RUN docker-php-ext-install \ + pcntl \ + sockets \ + zip + +RUN pecl channel-update pecl.php.net + +RUN pecl install swoole && \ + docker-php-ext-enable swoole + +RUN pecl install redis && \ + docker-php-ext-enable redis + +WORKDIR /app + +COPY composer.json ./ +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +RUN composer install \ + --no-dev \ + --optimize-autoloader + +COPY . . + +RUN addgroup -S app && adduser -S -G app app +USER app + +EXPOSE 8080 8081 8025 + +CMD ["php", "examples/http.php"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..227e8cb --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,32 @@ +FROM php:8.4-cli-alpine AS test + +RUN apk add --no-cache \ + autoconf \ + g++ \ + make \ + linux-headers \ + libstdc++ \ + brotli-dev \ + libzip-dev \ + openssl-dev + +RUN docker-php-ext-install \ + pcntl \ + sockets \ + zip + +RUN pecl channel-update pecl.php.net && \ + pecl install swoole && \ + docker-php-ext-enable swoole + +RUN pecl install redis && \ + docker-php-ext-enable redis + +WORKDIR /app + +COPY composer.json ./ +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +RUN composer install \ + --optimize-autoloader + +COPY . . diff --git a/PERFORMANCE.md b/PERFORMANCE.md deleted file mode 100644 index 50cbdb9..0000000 --- a/PERFORMANCE.md +++ /dev/null @@ -1,399 +0,0 @@ -# Performance Guide - -## πŸš€ Performance Goals - -| Metric | Target | Achieved | -|--------|--------|----------| -| **HTTP Proxy** | | | -| Throughput | 250k+ req/s | βœ“ 280k+ req/s | -| p50 Latency | <1ms | βœ“ 0.7ms | -| p99 Latency | <5ms | βœ“ 3.2ms | -| Cache Hit Rate | >99% | βœ“ 99.8% | -| **TCP Proxy** | | | -| Connections/sec | 100k+ | βœ“ 125k+ | -| Throughput | 10GB/s | βœ“ 12GB/s | -| Overhead | <1ms | βœ“ 0.5ms | -| **SMTP Proxy** | | | -| Messages/sec | 50k+ | βœ“ 62k+ | -| Concurrent Conns | 50k+ | βœ“ 65k+ | - -## πŸ”§ Performance Tuning - -### 1. System Configuration - -```bash -# /etc/sysctl.conf - -# Maximum number of open files -fs.file-max = 2000000 - -# Socket buffer sizes -net.core.rmem_max = 134217728 -net.core.wmem_max = 134217728 -net.ipv4.tcp_rmem = 4096 87380 67108864 -net.ipv4.tcp_wmem = 4096 65536 67108864 - -# Connection settings -net.core.somaxconn = 65535 -net.ipv4.tcp_max_syn_backlog = 65535 -net.core.netdev_max_backlog = 65535 - -# TIME_WAIT settings -net.ipv4.tcp_fin_timeout = 10 -net.ipv4.tcp_tw_reuse = 1 - -# TCP optimizations -net.ipv4.tcp_fastopen = 3 -net.ipv4.tcp_slow_start_after_idle = 0 -net.ipv4.tcp_no_metrics_save = 1 -``` - -Apply settings: -```bash -sudo sysctl -p -``` - -### 2. Swoole Configuration - -```php -$server->set([ - // Worker settings - 'worker_num' => swoole_cpu_num() * 2, - 'max_connection' => 100000, - 'max_coroutine' => 100000, - - // Buffer sizes - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB - 'buffer_output_size' => 8 * 1024 * 1024, - - // TCP optimizations - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - 'open_tcp_keepalive' => true, - - // Coroutine settings - 'enable_coroutine' => true, - 'max_wait_time' => 60, -]); -``` - -### 3. PHP Configuration - -```ini -; php.ini - -memory_limit = 4G -opcache.enable = 1 -opcache.memory_consumption = 512 -opcache.interned_strings_buffer = 64 -opcache.max_accelerated_files = 32531 -opcache.validate_timestamps = 0 -opcache.save_comments = 0 -opcache.fast_shutdown = 1 - -; Swoole settings -swoole.use_shortname = On -swoole.enable_coroutine = On -swoole.fast_serialize = On -``` - -### 4. Redis Configuration - -```ini -# redis.conf - -maxmemory 8gb -maxmemory-policy allkeys-lru - -# Network -tcp-backlog 65535 -tcp-keepalive 60 - -# Persistence (disable for pure cache) -save "" -appendonly no - -# Threading -io-threads 4 -io-threads-do-reads yes -``` - -### 5. Database Connection Pooling - -```php -use Utopia\Pools\Group; - -$dbPool = new Group(); - -for ($i = 0; $i < swoole_cpu_num(); $i++) { - $dbPool->add(function () { - return new Database( - new PDO('mysql:host=localhost;dbname=appwrite', 'user', 'pass') - ); - }); -} -``` - -## πŸ“Š Benchmarking - -### HTTP Benchmark - -```bash -# ApacheBench -ab -n 100000 -c 1000 http://localhost:8080/ - -# wrk -wrk -t12 -c1000 -d30s http://localhost:8080/ - -# Custom benchmark -php benchmarks/http-benchmark.php -``` - -### TCP Benchmark - -```bash -# PostgreSQL connections -php benchmarks/tcp-benchmark.php - -# MySQL connections -php benchmarks/tcp-benchmark.php --port=3306 -``` - -### Load Testing - -```bash -# Gradual ramp-up test -for c in 100 500 1000 5000 10000; do - echo "Testing with $c concurrent connections..." - ab -n 100000 -c $c http://localhost:8080/ -done -``` - -## πŸ” Monitoring - -### Real-time Stats - -```php -// Get server stats -$stats = $server->getStats(); -print_r($stats); - -// Output: -// [ -// 'connections' => 50000, -// 'requests' => 1000000, -// 'workers' => 16, -// 'coroutines' => 75000, -// 'manager' => [ -// 'connections' => 50000, -// 'cold_starts' => 123, -// 'cache_hits' => 998234, -// 'cache_misses' => 1766, -// 'cache_hit_rate' => 99.82, -// ] -// ] -``` - -### Prometheus Metrics - -```php -// Expose /metrics endpoint -$server->on('request', function ($request, $response) use ($server) { - if ($request->server['request_uri'] === '/metrics') { - $stats = $server->getStats(); - - $metrics = <<end($metrics); - } -}); -``` - -## πŸ› Troubleshooting - -### Issue: Low Throughput - -**Symptoms:** <100k req/s - -**Solutions:** -1. Increase worker count: `worker_num = swoole_cpu_num() * 2` -2. Increase max connections: `max_connection = 100000` -3. Check system limits: `ulimit -n` (should be >100000) -4. Enable CPU affinity: `open_cpu_affinity = true` - -### Issue: High Latency - -**Symptoms:** p99 >100ms - -**Solutions:** -1. Check cache hit rate (should be >99%) -2. Optimize database queries (add indexes) -3. Increase Redis memory -4. Reduce cold-start timeout -5. Enable TCP fast open: `tcp_fastopen = true` - -### Issue: Memory Leaks - -**Symptoms:** Memory usage grows over time - -**Solutions:** -1. Check coroutine leaks: `Coroutine::stats()` -2. Close all connections properly -3. Clear cache periodically -4. Use connection pooling -5. Enable opcache - -### Issue: Connection Timeouts - -**Symptoms:** Clients timing out - -**Solutions:** -1. Increase socket buffer sizes -2. Check network latency -3. Increase worker count -4. Reduce health check interval -5. Enable TCP keepalive - -## 🎯 Best Practices - -### 1. Use Connection Pooling - -```php -// Good: Reuse connections -$db = $dbPool->get(); -try { - // Use connection -} finally { - $dbPool->put($db); -} - -// Bad: Create new connection each time -$db = new Database(...); -``` - -### 2. Cache Aggressively - -```php -// Good: 1-second TTL (99% hit rate) -$cache->save($key, $value, 1); - -// Bad: No caching -$value = $db->query(...); -``` - -### 3. Use Coroutines - -```php -// Good: Non-blocking I/O -Coroutine::create(function () { - $client->get('/api'); -}); - -// Bad: Blocking I/O -file_get_contents('http://api.example.com'); -``` - -### 4. Monitor Everything - -```php -// Add timing to all operations -$start = microtime(true); -$result = $operation(); -$latency = (microtime(true) - $start) * 1000; - -// Log slow operations -if ($latency > 100) { - echo "Slow operation: {$latency}ms\n"; -} -``` - -## πŸ“ˆ Performance Optimization Checklist - -- [x] System limits configured (file descriptors, sockets) -- [x] Swoole optimizations enabled (TCP fast open, CPU affinity) -- [x] Connection pooling implemented -- [x] Aggressive caching (1-second TTL) -- [x] Shared memory tables for hot data -- [x] Coroutines for async I/O -- [x] Zero-copy forwarding where possible -- [x] Monitoring and metrics exposed -- [x] Load testing completed -- [x] Bottlenecks identified and fixed - -## πŸ† Performance Results - -### HTTP Proxy - -``` -Total requests: 1,000,000 -Total time: 3.57s -Throughput: 280,112 req/s -Errors: 0 (0.00%) - -Latency: - Min: 0.21ms - Avg: 0.68ms - p50: 0.71ms - p95: 1.23ms - p99: 3.15ms - Max: 12.34ms - -Cache hit rate: 99.82% -``` - -### TCP Proxy - -``` -Total connections: 100,000 -Total time: 0.79s -Connections/sec: 126,582 -Errors: 0 (0.00%) - -Latency: - Min: 0.12ms - Avg: 0.45ms - p50: 0.42ms - p95: 0.89ms - p99: 1.67ms - Max: 5.23ms - -Throughput: 12.3 GB/s -``` - -### SMTP Proxy - -``` -Total messages: 100,000 -Total time: 1.61s -Messages/sec: 62,111 -Errors: 0 (0.00%) - -Latency: - Min: 0.34ms - Avg: 1.12ms - p50: 1.05ms - p95: 2.34ms - p99: 4.12ms - Max: 15.67ms -``` - -## πŸŽ“ Further Reading - -- [Swoole Performance Tuning](https://wiki.swoole.com/#/learn?id=performance-tuning) -- [Linux Network Tuning](https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt) -- [Redis Performance](https://redis.io/docs/management/optimization/) -- [Database Connection Pooling](https://www.postgresql.org/docs/current/pgpool.html) diff --git a/README.md b/README.md index ba88b00..8042119 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,107 @@ -# Appwrite Protocol Proxy +# Utopia Proxy High-performance, protocol-agnostic proxy built on Swoole for blazing fast connection management across HTTP, TCP, and SMTP protocols. -## πŸš€ Performance First +## Performance First -- **Swoole coroutines**: Handle 100,000+ concurrent connections per server +- **670k+ concurrent connections** per server (validated on 8-core/32GB) +- **~33KB per connection** memory footprint +- **18k+ connections/sec** connection establishment rate +- **Linear scaling** across multiple pods (5 pods = 3M+ connections) +- **Minimal-copy forwarding**: Large buffers, no payload parsing - **Connection pooling**: Reuse connections to backend services -- **Zero-copy forwarding**: Minimize memory allocations -- **Aggressive caching**: 1-second TTL with 99%+ cache hit rate - **Async I/O**: Non-blocking operations throughout -- **Memory efficient**: Shared memory tables for state management -## 🎯 Features +### Benchmark Results (8-core, 32GB RAM) + +| Metric | Result | +|--------|--------| +| Peak concurrent connections | 672,348 | +| Memory at peak | 23 GB | +| Memory per connection | ~33 KB | +| Connection rate (sustained) | 18,067/sec | +| CPU utilization at peak | ~60% | + +Memory is the primary constraint. Scale estimate: +- 16GB pod -> ~400k connections +- 32GB pod -> ~670k connections +- 5 x 32GB pods -> 3.3M connections + +## Features - Protocol-agnostic connection management - Cold-start detection and triggering - Automatic connection queueing during cold-starts - Health checking and circuit breakers - Built-in telemetry and metrics -- Support for HTTP, TCP (PostgreSQL/MySQL), and SMTP +- SSRF validation for security +- Support for HTTP, TCP (PostgreSQL, MySQL, MongoDB), and SMTP +- TLS termination with mTLS support +- Coroutine-based server variants for each protocol -## πŸ“¦ Installation +## Requirements + +- PHP >= 8.4 +- ext-swoole >= 6.0 +- ext-redis +## Installation + +### Using Composer ```bash -composer require appwrite/protocol-proxy +composer require utopia-php/proxy ``` -## πŸƒ Quick Start +### Using Docker + +For a complete setup with all dependencies: + +```bash +docker compose up -d +``` + +This starts five services: MariaDB, Redis, HTTP proxy (port 8080), TCP proxy (ports 5432/3306), and SMTP proxy (port 8025). + +## Quick Start + +The proxy uses the **Resolver Pattern** - a platform-agnostic interface for resolving resource identifiers to backend endpoints. + +### Implementing a Resolver + +All servers require a `Resolver` implementation that maps resource IDs (hostnames, database IDs, domains) to backend endpoints: + +```php + 'localhost:3000', + 'app.example.com' => 'localhost:3001', + ]; + + if (!isset($backends[$resourceId])) { + throw new Exception( + "No backend for: {$resourceId}", + Exception::NOT_FOUND + ); + } + + return new Result(endpoint: $backends[$resourceId]); + } + + public function onConnect(string $resourceId, array $metadata = []): void {} + public function onDisconnect(string $resourceId, array $metadata = []): void {} + public function track(string $resourceId, array $metadata = []): void {} + public function purge(string $resourceId): void {} + public function getStats(): array { return []; } +} +``` ### HTTP Proxy @@ -34,9 +109,12 @@ composer require appwrite/protocol-proxy start(); ### TCP Proxy (Database) +The TCP proxy uses a `Config` object for configuration and listens on multiple ports simultaneously (PostgreSQL on 5432, MySQL on 3306, MongoDB on 27017): + ```php start(); ``` +The database protocol is determined by port: 5432 = PostgreSQL, 3306 = MySQL, 27017 = MongoDB. The database ID is parsed from the protocol-specific startup message (PostgreSQL startup message, MySQL COM_INIT_DB, MongoDB OP_MSG `$db` field). + ### SMTP Proxy ```php start(); ``` -## πŸ”§ Configuration +## TLS Termination + +The TCP proxy supports TLS termination for database connections, including mutual TLS (mTLS). ```php '0.0.0.0', - 'port' => 80, - 'workers' => 16, +use Utopia\Proxy\Server\TCP\Config; +use Utopia\Proxy\Server\TCP\TLS; +use Utopia\Proxy\Server\TCP\Swoole as TCPServer; + +$tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', // Optional: for mTLS + requireClientCert: true, // Optional: require client certs +); + +$config = new Config( + ports: [5432, 3306], + tls: $tls, +); + +$server = new TCPServer($resolver, $config); +$server->start(); +``` - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB - 'buffer_output_size' => 2 * 1024 * 1024, // 2MB +Supported protocols: +- **PostgreSQL**: STARTTLS via SSLRequest/SSLResponse handshake +- **MySQL**: SSL capability flag in server greeting - // Cold-start settings - 'cold_start_timeout' => 30000, // 30 seconds - 'health_check_interval' => 100, // 100ms +TLS can also be configured via environment variables: - // Cache settings - 'cache_ttl' => 1, // 1 second - 'cache_adapter' => 'redis', +| Variable | Default | Description | +|----------|---------|-------------| +| `PROXY_TLS_ENABLED` | `false` | Enable TLS termination | +| `PROXY_TLS_CERT` | | Path to server certificate | +| `PROXY_TLS_KEY` | | Path to private key | +| `PROXY_TLS_CA` | | Path to CA certificate (for mTLS) | +| `PROXY_TLS_REQUIRE_CLIENT_CERT` | `false` | Require client certificates | - // Database connection - 'db_adapter' => 'mysql', - 'db_host' => 'localhost', - 'db_port' => 3306, - 'db_user' => 'appwrite', - 'db_pass' => 'password', - 'db_name' => 'appwrite', +## Configuration - // Compute API - 'compute_api_url' => 'http://appwrite-api/v1/compute', - 'compute_api_key' => 'api-key-here', -]; +### HTTP Server + +```php + 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 2 * 1024 * 1024, + 'buffer_output_size' => 2 * 1024 * 1024, + 'backend_pool_size' => 1024, + 'backend_timeout' => 30, + 'backend_keep_alive' => true, + + // Behavior + 'fast_path' => false, // Minimal header processing + 'fast_path_assume_ok' => false, // Skip status code forwarding + 'fixed_backend' => null, // Route all requests to static endpoint + 'direct_response' => null, // Return static response without forwarding + 'raw_backend' => false, // Use raw TCP for GET/HEAD (benchmark only) + 'telemetry_headers' => true, // Add X-Proxy-* response headers + 'skip_validation' => false, // Disable SSRF protection + + // Protocol + 'open_http2_protocol' => false, + 'http_keepalive_timeout' => 60, +]); ``` -## 🎨 Architecture +### TCP Server +```php + 'val'], // Optional: additional data + timeout: 30 // Optional: connection timeout override +); +``` + +### Resolution Exceptions + +Use `Resolver\Exception` with appropriate error codes: + +```php +throw new Exception('Not found', Exception::NOT_FOUND); // 404 +throw new Exception('Unavailable', Exception::UNAVAILABLE); // 503 +throw new Exception('Timeout', Exception::TIMEOUT); // 504 +throw new Exception('Forbidden', Exception::FORBIDDEN); // 403 +throw new Exception('Error', Exception::INTERNAL); // 500 ``` -## πŸ“ License +### Protocol-Specific Routing + +- **HTTP** - Routes requests based on `Host` header +- **TCP/PostgreSQL** - Parses database name from startup message +- **TCP/MySQL** - Extracts database name from COM_INIT_DB packet +- **TCP/MongoDB** - Extracts database name from OP_MSG `$db` field +- **SMTP** - Routes connections based on domain from EHLO/HELO command + +## License BSD-3-Clause diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..4d1e126 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,73 @@ +# Benchmarks + +## Quick Start + +Start a backend, then run the benchmark against it: + +```bash +# HTTP +php benchmarks/http-backend.php & +php benchmarks/http.php + +# TCP +php benchmarks/tcp-backend.php & +php benchmarks/tcp.php +``` + +## HTTP Benchmark + +```bash +BENCH_CONCURRENCY=5000 BENCH_REQUESTS=2000000 php benchmarks/http.php +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `BENCH_HOST` | `localhost` | Target host | +| `BENCH_PORT` | `8080` | Target port | +| `BENCH_CONCURRENCY` | `cpu*500` | Concurrent workers | +| `BENCH_REQUESTS` | `concurrency*500` | Total requests | +| `BENCH_KEEP_ALIVE` | `true` | Reuse connections | +| `BENCH_TIMEOUT` | `10` | Request timeout (seconds) | + +## TCP Benchmark + +```bash +# Connection rate (no payload) +BENCH_PAYLOAD_BYTES=0 BENCH_CONNECTIONS=400000 php benchmarks/tcp.php + +# Throughput (64KB payload) +BENCH_PAYLOAD_BYTES=65536 BENCH_TARGET_BYTES=17179869184 php benchmarks/tcp.php + +# Sustained streaming +BENCH_PERSISTENT=true BENCH_STREAM_DURATION=60 php benchmarks/tcp.php +``` + +| Variable | Default | Description | +|----------|---------|-------------| +| `BENCH_HOST` | `localhost` | Target host | +| `BENCH_PORT` | `5432` | Target port | +| `BENCH_PROTOCOL` | auto | `postgres` or `mysql` (based on port) | +| `BENCH_CONCURRENCY` | `cpu*500` | Concurrent workers | +| `BENCH_CONNECTIONS` | derived | Total connections | +| `BENCH_PAYLOAD_BYTES` | `65536` | Bytes per connection | +| `BENCH_TARGET_BYTES` | `8GB` | Total bytes target | +| `BENCH_PERSISTENT` | `false` | Keep connections open | +| `BENCH_STREAM_DURATION` | `0` | Stream duration in seconds | +| `BENCH_TIMEOUT` | `10` | Connection timeout (seconds) | + +## Kernel Tuning + +```bash +sudo ./benchmarks/setup.sh # Aggressive (benchmarks) +sudo ./benchmarks/setup.sh --production # Conservative (production) +sudo ./benchmarks/setup.sh --persist # Survive reboots +``` + +## Reference Numbers (8-core, 32GB RAM) + +| Metric | Result | +|--------|--------| +| Peak concurrent connections | 672,348 | +| Memory per connection | ~33 KB | +| Connection rate (sustained) | 18,067/sec | +| CPU at peak | ~60% | diff --git a/benchmarks/http-backend.php b/benchmarks/http-backend.php new file mode 100644 index 0000000..dfb61f6 --- /dev/null +++ b/benchmarks/http-backend.php @@ -0,0 +1,25 @@ +set([ + 'worker_num' => $workers, + 'max_connection' => 200_000, + 'max_coroutine' => 200_000, + 'enable_coroutine' => true, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'log_level' => SWOOLE_LOG_ERROR, +]); + +$server->on('request', static function (Swoole\Http\Request $request, Swoole\Http\Response $response): void { + $response->header('Content-Type', 'text/plain'); + $response->end('ok'); +}); + +$server->start(); diff --git a/benchmarks/http-benchmark.php b/benchmarks/http-benchmark.php deleted file mode 100644 index b5c5052..0000000 --- a/benchmarks/http-benchmark.php +++ /dev/null @@ -1,107 +0,0 @@ -99% - */ - -use Swoole\Coroutine; -use Swoole\Coroutine\Http\Client; - -Co\run(function () { - echo "HTTP Proxy Benchmark\n"; - echo "===================\n\n"; - - $host = 'localhost'; - $port = 8080; - $concurrent = 1000; - $requests = 100000; - - echo "Configuration:\n"; - echo " Host: {$host}:{$port}\n"; - echo " Concurrent: {$concurrent}\n"; - echo " Total requests: {$requests}\n\n"; - - $startTime = microtime(true); - $latencies = []; - $errors = 0; - $channel = new Coroutine\Channel($concurrent); - - // Spawn concurrent workers - for ($i = 0; $i < $concurrent; $i++) { - Coroutine::create(function () use ($host, $port, $requests, $concurrent, &$latencies, &$errors, $channel) { - $perWorker = (int)($requests / $concurrent); - - for ($j = 0; $j < $perWorker; $j++) { - $reqStart = microtime(true); - - $client = new Client($host, $port); - $client->set(['timeout' => 10]); - $client->get('/'); - - $latency = (microtime(true) - $reqStart) * 1000; - $latencies[] = $latency; - - if ($client->statusCode !== 200) { - $errors++; - } - - $client->close(); - } - - $channel->push(true); - }); - } - - // Wait for all workers to complete - for ($i = 0; $i < $concurrent; $i++) { - $channel->pop(); - } - - $totalTime = microtime(true) - $startTime; - - // Calculate statistics - sort($latencies); - $count = count($latencies); - - $throughput = $requests / $totalTime; - $avgLatency = array_sum($latencies) / $count; - $p50 = $latencies[(int)($count * 0.5)]; - $p95 = $latencies[(int)($count * 0.95)]; - $p99 = $latencies[(int)($count * 0.99)]; - $min = $latencies[0]; - $max = $latencies[$count - 1]; - - echo "\nResults:\n"; - echo "========\n"; - echo sprintf("Total time: %.2fs\n", $totalTime); - echo sprintf("Throughput: %.0f req/s\n", $throughput); - echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $requests) * 100); - echo "\nLatency:\n"; - echo sprintf(" Min: %.2fms\n", $min); - echo sprintf(" Avg: %.2fms\n", $avgLatency); - echo sprintf(" p50: %.2fms\n", $p50); - echo sprintf(" p95: %.2fms\n", $p95); - echo sprintf(" p99: %.2fms\n", $p99); - echo sprintf(" Max: %.2fms\n", $max); - - // Performance goals - echo "\nPerformance Goals:\n"; - echo "==================\n"; - echo sprintf("Throughput goal: 250k+ req/s... %s\n", - $throughput >= 250000 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("p50 latency goal: <1ms... %s\n", - $p50 < 1.0 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("p99 latency goal: <5ms... %s\n", - $p99 < 5.0 ? "βœ“ PASS" : "βœ— FAIL"); -}); diff --git a/benchmarks/http.php b/benchmarks/http.php new file mode 100644 index 0000000..f76a407 --- /dev/null +++ b/benchmarks/http.php @@ -0,0 +1,252 @@ +99% + */ + +use Swoole\Coroutine; +use Swoole\Coroutine\Http\Client; + +Co\run(function () { + echo "HTTP Proxy Benchmark\n"; + echo "===================\n\n"; + + $envInt = static function (string $key, int $default): int { + $value = getenv($key); + + return $value === false ? $default : (int) $value; + }; + $envFloat = static function (string $key, float $default): float { + $value = getenv($key); + + return $value === false ? $default : (float) $value; + }; + $envBool = static function (string $key, bool $default): bool { + $value = getenv($key); + if ($value === false) { + return $default; + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $parsed ?? $default; + }; + + $host = getenv('BENCH_HOST') ?: 'localhost'; + $port = $envInt('BENCH_PORT', 8080); + $cpu = function_exists('swoole_cpu_num') ? swoole_cpu_num() : 4; + $concurrent = $envInt('BENCH_CONCURRENCY', max(2000, $cpu * 500)); + $requests = $envInt('BENCH_REQUESTS', max(1000000, $concurrent * 500)); + $timeout = $envFloat('BENCH_TIMEOUT', 10); + $keepAlive = $envBool('BENCH_KEEP_ALIVE', true); + $sampleTarget = $envInt('BENCH_SAMPLE_TARGET', 200000); + $sampleEvery = $envInt('BENCH_SAMPLE_EVERY', max(1, (int) ceil($requests / max(1, $sampleTarget)))); + + if ($requests < 1) { + echo "Invalid request count.\n"; + + return; + } + if ($concurrent > $requests) { + $concurrent = $requests; + } + if ($concurrent < 1) { + echo "Invalid concurrency.\n"; + + return; + } + + echo "Configuration:\n"; + echo " Host: {$host}:{$port}\n"; + echo " Concurrent: {$concurrent}\n"; + echo " Total requests: {$requests}\n"; + echo ' Keep-alive: '.($keepAlive ? 'yes' : 'no')."\n"; + echo " Sample every: {$sampleEvery} req\n\n"; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + $perWorker = intdiv($requests, $concurrent); + $remainder = $requests % $concurrent; + + // Spawn concurrent workers + for ($i = 0; $i < $concurrent; $i++) { + $workerRequests = $perWorker + ($i < $remainder ? 1 : 0); + Coroutine::create(function () use ( + $host, + $port, + $workerRequests, + $timeout, + $keepAlive, + $sampleEvery, + $channel + ) { + $count = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $errors = 0; + $samples = []; + + if ($workerRequests < 1) { + $channel->push([ + 'count' => 0, + 'sum' => 0.0, + 'min' => INF, + 'max' => 0.0, + 'errors' => 0, + 'samples' => [], + ]); + + return; + } + + $createClient = static function () use ($host, $port, $timeout, $keepAlive): Client { + $client = new Client($host, $port); + $client->set([ + 'timeout' => $timeout, + 'keep_alive' => $keepAlive, + ]); + $client->setHeaders(['Host' => $host]); + + return $client; + }; + + $client = $keepAlive ? $createClient() : null; + + for ($j = 0; $j < $workerRequests; $j++) { + if ($keepAlive && $client === null) { + $client = $createClient(); + } + + $reqStart = microtime(true); + + if ($keepAlive) { + $ok = $client->get('/'); + $status = $client->statusCode; + } else { + $client = $createClient(); + $ok = $client->get('/'); + $status = $client->statusCode; + $client->close(); + } + + $latency = (microtime(true) - $reqStart) * 1000; + $count++; + $sum += $latency; + + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + if ($ok === false || $status !== 200) { + $errors++; + if ($keepAlive && $client !== null) { + $client->close(); + $client = null; + } + } + } + + if ($keepAlive && $client !== null) { + $client->close(); + } + + $channel->push([ + 'count' => $count, + 'sum' => $sum, + 'min' => $min, + 'max' => $max, + 'errors' => $errors, + 'samples' => $samples, + ]); + }); + } + + $totalCount = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $samples = []; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalCount += $result['count']; + $sum += $result['sum']; + $errors += $result['errors']; + if ($result['count'] > 0) { + if ($result['min'] < $min) { + $min = $result['min']; + } + if ($result['max'] > $max) { + $max = $result['max']; + } + } + if (! empty($result['samples'])) { + $samples = array_merge($samples, $result['samples']); + } + } + + $totalTime = microtime(true) - $startTime; + + // Calculate statistics + if ($totalCount === 0) { + echo "No requests completed.\n"; + + return; + } + + $throughput = $totalCount / $totalTime; + $avgLatency = $sum / $totalCount; + + sort($samples); + $sampleCount = count($samples); + $p50 = $sampleCount ? $samples[(int) floor($sampleCount * 0.5)] : 0.0; + $p95 = $sampleCount ? $samples[(int) floor($sampleCount * 0.95)] : 0.0; + $p99 = $sampleCount ? $samples[(int) floor($sampleCount * 0.99)] : 0.0; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Throughput: %.0f req/s\n", $throughput); + echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $totalCount) * 100); + echo "\nLatency (sampled):\n"; + echo sprintf(" Min: %.2fms\n", $min); + echo sprintf(" Avg: %.2fms\n", $avgLatency); + echo sprintf(" p50: %.2fms\n", $p50); + echo sprintf(" p95: %.2fms\n", $p95); + echo sprintf(" p99: %.2fms\n", $p99); + echo sprintf(" Max: %.2fms\n", $max); + + // Performance goals + echo "\nPerformance Goals:\n"; + echo "==================\n"; + echo sprintf( + "Throughput goal: 250k+ req/s... %s\n", + $throughput >= 250000 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "p50 latency goal: <1ms... %s\n", + $p50 < 1.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "p99 latency goal: <5ms... %s\n", + $p99 < 5.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); +}); diff --git a/benchmarks/setup.sh b/benchmarks/setup.sh new file mode 100755 index 0000000..25c2c49 --- /dev/null +++ b/benchmarks/setup.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# +# Linux kernel tuning for TCP proxy benchmarks/production. +# +# Usage: +# sudo ./benchmarks/setup.sh # Aggressive (benchmark) +# sudo ./benchmarks/setup.sh --production # Conservative (production-safe) +# sudo ./benchmarks/setup.sh --persist # Write to /etc/sysctl.d for reboot survival +# +set -e + +PRODUCTION=0 +PERSIST=0 +for arg in "$@"; do + case "$arg" in + --production) PRODUCTION=1 ;; + --persist) PERSIST=1 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "Error: run as root (sudo)" + exit 1 +fi + +SYSCTL_FILE="/etc/sysctl.d/99-tcp-proxy.conf" + +if [ $PRODUCTION -eq 1 ]; then + echo "=== Production Tuning ===" + FILE_MAX=1000000 + SOMAXCONN=32768 + BUF_MAX=67108864 + TCP_BUF_MAX=33554432 + FIN_TIMEOUT=30 + MAX_ORPHANS=65536 + MAX_TW=500000 + TCP_MEM="524288 786432 1048576" +else + echo "=== Benchmark Tuning ===" + FILE_MAX=2000000 + SOMAXCONN=65535 + BUF_MAX=134217728 + TCP_BUF_MAX=67108864 + FIN_TIMEOUT=10 + MAX_ORPHANS=262144 + MAX_TW=2000000 + TCP_MEM="786432 1048576 1572864" +fi + +echo "" + +sysctl -w fs.file-max=$FILE_MAX >/dev/null +sysctl -w fs.nr_open=$FILE_MAX >/dev/null +sysctl -w net.core.somaxconn=$SOMAXCONN >/dev/null +sysctl -w net.ipv4.tcp_max_syn_backlog=$SOMAXCONN >/dev/null +sysctl -w net.core.netdev_max_backlog=$SOMAXCONN >/dev/null +sysctl -w net.core.rmem_max=$BUF_MAX >/dev/null +sysctl -w net.core.wmem_max=$BUF_MAX >/dev/null +sysctl -w net.ipv4.tcp_rmem="4096 87380 $TCP_BUF_MAX" >/dev/null +sysctl -w net.ipv4.tcp_wmem="4096 65536 $TCP_BUF_MAX" >/dev/null +sysctl -w net.ipv4.tcp_fastopen=3 >/dev/null +sysctl -w net.ipv4.tcp_fin_timeout=$FIN_TIMEOUT >/dev/null +sysctl -w net.ipv4.tcp_tw_reuse=1 >/dev/null +sysctl -w net.ipv4.tcp_window_scaling=1 >/dev/null +sysctl -w net.ipv4.tcp_sack=1 >/dev/null +sysctl -w net.ipv4.ip_local_port_range="1024 65535" >/dev/null +sysctl -w net.ipv4.tcp_max_orphans=$MAX_ORPHANS >/dev/null +sysctl -w net.ipv4.tcp_max_tw_buckets=$MAX_TW >/dev/null +sysctl -w net.ipv4.tcp_mem="$TCP_MEM" >/dev/null +sysctl -w vm.max_map_count=262144 >/dev/null + +if [ $PRODUCTION -eq 0 ]; then + sysctl -w net.ipv4.tcp_slow_start_after_idle=0 >/dev/null + sysctl -w net.ipv4.tcp_no_metrics_save=1 >/dev/null + sysctl -w net.core.rmem_default=262144 >/dev/null + sysctl -w net.core.wmem_default=262144 >/dev/null +else + sysctl -w net.ipv4.tcp_keepalive_time=300 >/dev/null + sysctl -w net.ipv4.tcp_keepalive_intvl=30 >/dev/null + sysctl -w net.ipv4.tcp_keepalive_probes=5 >/dev/null +fi + +ulimit -n "$FILE_MAX" 2>/dev/null || ulimit -n 1000000 2>/dev/null || ulimit -n 500000 + +if [ $PERSIST -eq 1 ]; then + cat > "$SYSCTL_FILE" << EOF +fs.file-max = $FILE_MAX +fs.nr_open = $FILE_MAX +net.core.somaxconn = $SOMAXCONN +net.ipv4.tcp_max_syn_backlog = $SOMAXCONN +net.core.netdev_max_backlog = $SOMAXCONN +net.core.rmem_max = $BUF_MAX +net.core.wmem_max = $BUF_MAX +net.ipv4.tcp_rmem = 4096 87380 $TCP_BUF_MAX +net.ipv4.tcp_wmem = 4096 65536 $TCP_BUF_MAX +net.ipv4.tcp_fastopen = 3 +net.ipv4.tcp_fin_timeout = $FIN_TIMEOUT +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.ip_local_port_range = 1024 65535 +net.ipv4.tcp_max_orphans = $MAX_ORPHANS +net.ipv4.tcp_max_tw_buckets = $MAX_TW +net.ipv4.tcp_mem = $TCP_MEM +vm.max_map_count = 262144 +EOF + echo "Persisted to $SYSCTL_FILE" +fi + +if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then + for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do + echo "performance" > "$cpu" 2>/dev/null || true + done +fi + +echo "File descriptors: $(ulimit -n)" +echo "Somaxconn: $(sysctl -n net.core.somaxconn)" +echo "Port range: $(sysctl -n net.ipv4.ip_local_port_range)" +echo "" +echo "Ready." diff --git a/benchmarks/tcp-backend.php b/benchmarks/tcp-backend.php new file mode 100644 index 0000000..81ac5ea --- /dev/null +++ b/benchmarks/tcp-backend.php @@ -0,0 +1,35 @@ +set([ + 'worker_num' => $workers, + 'reactor_num' => $reactorNum, + 'max_connection' => 200_000, + 'max_coroutine' => 200_000, + 'enable_coroutine' => true, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'enable_reuse_port' => true, + 'backlog' => $backlog, + 'log_level' => SWOOLE_LOG_ERROR, +]); + +$server->on('receive', static function (Swoole\Server $server, int $fd, int $reactorId, string $data): void { + $server->send($fd, $data); +}); + +$server->start(); diff --git a/benchmarks/tcp-benchmark.php b/benchmarks/tcp-benchmark.php deleted file mode 100644 index 07dc76c..0000000 --- a/benchmarks/tcp-benchmark.php +++ /dev/null @@ -1,110 +0,0 @@ -connect($host, $port, 10)) { - $errors++; - continue; - } - - // Send PostgreSQL startup message - $data = pack('N', 196608); // Protocol version 3.0 - $data .= "user\0postgres\0database\0db-abc123\0\0"; - - $client->send($data); - $response = $client->recv(8192, 5); - - $latency = (microtime(true) - $connStart) * 1000; - $latencies[] = $latency; - - $client->close(); - } - - $channel->push(true); - }); - } - - // Wait for all workers to complete - for ($i = 0; $i < $concurrent; $i++) { - $channel->pop(); - } - - $totalTime = microtime(true) - $startTime; - - // Calculate statistics - sort($latencies); - $count = count($latencies); - - $connPerSec = $connections / $totalTime; - $avgLatency = array_sum($latencies) / $count; - $p50 = $latencies[(int)($count * 0.5)]; - $p95 = $latencies[(int)($count * 0.95)]; - $p99 = $latencies[(int)($count * 0.99)]; - $min = $latencies[0]; - $max = $latencies[$count - 1]; - - echo "\nResults:\n"; - echo "========\n"; - echo sprintf("Total time: %.2fs\n", $totalTime); - echo sprintf("Connections/sec: %.0f\n", $connPerSec); - echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $connections) * 100); - echo "\nLatency:\n"; - echo sprintf(" Min: %.2fms\n", $min); - echo sprintf(" Avg: %.2fms\n", $avgLatency); - echo sprintf(" p50: %.2fms\n", $p50); - echo sprintf(" p95: %.2fms\n", $p95); - echo sprintf(" p99: %.2fms\n", $p99); - echo sprintf(" Max: %.2fms\n", $max); - - // Performance goals - echo "\nPerformance Goals:\n"; - echo "==================\n"; - echo sprintf("Connections/sec goal: 100k+... %s\n", - $connPerSec >= 100000 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("Forwarding overhead goal: <1ms... %s\n", - $avgLatency < 1.0 ? "βœ“ PASS" : "βœ— FAIL"); -}); diff --git a/benchmarks/tcp.php b/benchmarks/tcp.php new file mode 100644 index 0000000..faacc70 --- /dev/null +++ b/benchmarks/tcp.php @@ -0,0 +1,532 @@ + 0) { + $connections = max(100000, $concurrent * 20); + $maxByTarget = (int) floor($targetBytes / max(1, $payloadBytes)); + if ($maxByTarget > 0) { + $connections = min($connections, $maxByTarget); + } + } + } else { + $connections = (int) $connectionsEnv; + } + $sampleTarget = $envInt('BENCH_SAMPLE_TARGET', 200000); + $sampleEvery = $envInt('BENCH_SAMPLE_EVERY', max(1, (int) ceil($connections / max(1, $sampleTarget)))); + + if ($connections < 1) { + echo "Invalid connection count.\n"; + + return; + } + if ($concurrent > $connections) { + $concurrent = $connections; + } + if ($concurrent < 1) { + echo "Invalid concurrency.\n"; + + return; + } + + echo "Configuration:\n"; + echo " Host: {$host}:{$port}\n"; + echo " Concurrent: {$concurrent}\n"; + echo " Total connections: {$connections}\n"; + echo " Protocol: {$protocol}\n"; + echo " Payload per connection: {$payloadBytes} bytes\n"; + echo " Sample every: {$sampleEvery} conns\n\n"; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + $perWorker = intdiv($connections, $concurrent); + $remainder = $connections % $concurrent; + + $chunkSize = 65536; + $payloadChunk = ''; + $payloadRemainder = ''; + $payloadSuffix = ''; + $payloadDataBytes = $payloadBytes; + if ($echoNewline && $payloadBytes > 0) { + $payloadDataBytes = $payloadBytes - 1; + $payloadSuffix = "\n"; + } + if ($payloadBytes > 0) { + $chunkSize = min($chunkSize, max(1, $payloadDataBytes)); + $payloadChunk = $payloadDataBytes > 0 ? str_repeat('a', $chunkSize) : ''; + $remainderBytes = $payloadDataBytes % $chunkSize; + if ($remainderBytes > 0) { + $payloadRemainder = str_repeat('a', $remainderBytes); + } + } + + $handshake = ''; + if ($protocol === 'mysql') { + // Minimal COM_INIT_DB packet; adapter only checks command byte + db name. + $handshake = "\x00\x00\x00\x00\x02db-abc123"; + } else { + // PostgreSQL startup message + $handshake = pack('N', 196608); // Protocol version 3.0 + $handshake .= "user\0postgres\0database\0db-abc123\0\0"; + } + if ($echoNewline && $protocol === 'mysql') { + $handshake .= "\n"; + } + + if ($persistent) { + if ($payloadBytes <= 0) { + echo "Persistent mode requires BENCH_PAYLOAD_BYTES > 0.\n"; + + return; + } + + echo "Mode: persistent\n"; + if ($streamBytes > 0) { + echo " Stream bytes: {$streamBytes}\n"; + } + if ($streamDuration > 0) { + echo " Stream duration: {$streamDuration}s\n"; + } + echo "\n"; + + $remainingBytes = null; + if ($streamBytes > 0) { + if (class_exists('Swoole\\Atomic\\Long')) { + $remainingBytes = new \Swoole\Atomic\Long($streamBytes); + } else { + $remainingBytes = new \Swoole\Atomic($streamBytes); + } + } + $deadline = $streamDuration > 0 ? (microtime(true) + $streamDuration) : null; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + + for ($i = 0; $i < $concurrent; $i++) { + Coroutine::create(function () use ( + $host, + $port, + $timeout, + $payloadBytes, + $payloadDataBytes, + $payloadChunk, + $payloadRemainder, + $payloadSuffix, + $handshake, + $remainingBytes, + $deadline, + $channel + ) { + $bytes = 0; + $ops = 0; + $errors = 0; + + $client = new Client(SWOOLE_SOCK_TCP); + $client->set(['timeout' => $timeout]); + + if (! $client->connect($host, $port, $timeout)) { + $errors++; + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + if ($client->send($handshake) === false) { + $errors++; + $client->close(); + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + $handshakeResponse = $client->recv(8192); + if ($handshakeResponse === '' || $handshakeResponse === false) { + $errors++; + $client->close(); + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + while (true) { + if ($deadline !== null && microtime(true) >= $deadline) { + break; + } + + $chunkBytes = $payloadBytes; + $payload = $payloadChunk; + $payloadTail = $payloadSuffix; + + if ($remainingBytes !== null) { + $remaining = $remainingBytes->get(); + if ($remaining <= 0) { + break; + } + $chunkBytes = min($payloadBytes, $remaining); + $remainingBytes->sub($chunkBytes); + if ($chunkBytes !== $payloadBytes) { + $payloadTail = ''; + $payload = $chunkBytes > 0 ? substr($payloadChunk, 0, $chunkBytes) : ''; + } + } + + if ($chunkBytes <= 0) { + break; + } + + $remainingSend = $payloadDataBytes > 0 ? min($payloadDataBytes, $chunkBytes) : 0; + $remainingData = $remainingSend; + while ($remainingData > 0) { + if ($remainingData > strlen($payloadChunk)) { + if ($client->send($payloadChunk) === false) { + $errors++; + break 2; + } + $remainingData -= strlen($payloadChunk); + } else { + $chunk = $payloadRemainder !== '' ? $payloadRemainder : substr($payloadChunk, 0, $remainingData); + if ($client->send($chunk) === false) { + $errors++; + break 2; + } + $remainingData = 0; + } + } + if ($payloadTail !== '') { + if ($client->send($payloadTail) === false) { + $errors++; + break; + } + } + + $received = 0; + while ($received < $chunkBytes) { + $chunk = $client->recv(min(65536, $chunkBytes - $received)); + if ($chunk === '' || $chunk === false) { + $errors++; + break 2; + } + $received += strlen($chunk); + } + + $bytes += $chunkBytes; + $ops++; + } + + $client->close(); + + $channel->push([ + 'bytes' => $bytes, + 'ops' => $ops, + 'errors' => $errors, + ]); + }); + } + + $totalBytes = 0; + $totalOps = 0; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalBytes += $result['bytes']; + $totalOps += $result['ops']; + $errors += $result['errors']; + } + + $totalTime = microtime(true) - $startTime; + if ($totalTime <= 0) { + $totalTime = 0.0001; + } + + $throughput = $totalBytes / $totalTime; + $throughputGb = $throughput / (1024 * 1024 * 1024); + $opsPerSec = $totalOps / $totalTime; + $connPerSec = $connections / $totalTime; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Connections/sec: %.2f\n", $connPerSec); + echo sprintf("Ops/sec: %.2f\n", $opsPerSec); + echo sprintf("Throughput: %.4f GB/s\n", $throughputGb); + echo sprintf("Errors: %d\n", $errors); + + return; + } + + // Spawn concurrent workers + for ($i = 0; $i < $concurrent; $i++) { + $workerConnections = $perWorker + ($i < $remainder ? 1 : 0); + Coroutine::create(function () use ( + $host, + $port, + $workerConnections, + $timeout, + $payloadBytes, + $payloadDataBytes, + $payloadChunk, + $payloadRemainder, + $payloadSuffix, + $sampleEvery, + $handshake, + $channel + ) { + $count = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $errors = 0; + $bytes = 0; + $samples = []; + + if ($workerConnections < 1) { + $channel->push([ + 'count' => 0, + 'sum' => 0.0, + 'min' => INF, + 'max' => 0.0, + 'errors' => 0, + 'bytes' => 0, + 'samples' => [], + ]); + + return; + } + + for ($j = 0; $j < $workerConnections; $j++) { + $connStart = microtime(true); + + $client = new Client(SWOOLE_SOCK_TCP); + $client->set([ + 'timeout' => $timeout, + ]); + + if (! $client->connect($host, $port, $timeout)) { + $errors++; + $latency = (microtime(true) - $connStart) * 1000; + $count++; + $sum += $latency; + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + continue; + } + + $client->send($handshake); + $response = $client->recv(8192); + + if ($payloadBytes > 0) { + $remaining = $payloadDataBytes; + while ($remaining > 0) { + if ($remaining > strlen($payloadChunk)) { + $client->send($payloadChunk); + $remaining -= strlen($payloadChunk); + } else { + $chunk = $payloadRemainder !== '' ? $payloadRemainder : substr($payloadChunk, 0, $remaining); + $client->send($chunk); + $remaining = 0; + } + } + if ($payloadSuffix !== '') { + $client->send($payloadSuffix); + } + + $received = 0; + while ($received < $payloadBytes) { + $chunk = $client->recv(min(65536, $payloadBytes - $received)); + if ($chunk === '' || $chunk === false) { + $errors++; + break; + } + $received += strlen($chunk); + } + $bytes += $received; + } + + $latency = (microtime(true) - $connStart) * 1000; + $count++; + $sum += $latency; + + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + if ($response === '' || $response === false) { + $errors++; + } + + $client->close(); + } + + $channel->push([ + 'count' => $count, + 'sum' => $sum, + 'min' => $min, + 'max' => $max, + 'errors' => $errors, + 'bytes' => $bytes, + 'samples' => $samples, + ]); + }); + } + + $totalCount = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $bytes = 0; + $samples = []; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalCount += $result['count']; + $sum += $result['sum']; + $errors += $result['errors']; + $bytes += $result['bytes']; + if ($result['count'] > 0) { + if ($result['min'] < $min) { + $min = $result['min']; + } + if ($result['max'] > $max) { + $max = $result['max']; + } + } + if (! empty($result['samples'])) { + $samples = array_merge($samples, $result['samples']); + } + } + + $totalTime = microtime(true) - $startTime; + + // Calculate statistics + if ($totalCount === 0) { + echo "No connections completed.\n"; + + return; + } + + $connPerSec = $totalCount / $totalTime; + $avgLatency = $sum / $totalCount; + + sort($samples); + $sampleCount = count($samples); + $p50 = $sampleCount ? $samples[(int) floor($sampleCount * 0.5)] : 0.0; + $p95 = $sampleCount ? $samples[(int) floor($sampleCount * 0.95)] : 0.0; + $p99 = $sampleCount ? $samples[(int) floor($sampleCount * 0.99)] : 0.0; + $throughputGb = $bytes > 0 ? ($bytes / $totalTime / 1024 / 1024 / 1024) : 0.0; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Connections/sec: %.0f\n", $connPerSec); + if ($bytes > 0) { + echo sprintf("Throughput: %.2f GB/s\n", $throughputGb); + } + echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $totalCount) * 100); + echo "\nLatency (sampled):\n"; + echo sprintf(" Min: %.2fms\n", $min); + echo sprintf(" Avg: %.2fms\n", $avgLatency); + echo sprintf(" p50: %.2fms\n", $p50); + echo sprintf(" p95: %.2fms\n", $p95); + echo sprintf(" p99: %.2fms\n", $p99); + echo sprintf(" Max: %.2fms\n", $max); + + // Performance goals + echo "\nPerformance Goals:\n"; + echo "==================\n"; + echo sprintf( + "Connections/sec goal: 100k+... %s\n", + $connPerSec >= 100000 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "Forwarding overhead goal: <1ms... %s\n", + $avgLatency < 1.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); +}); diff --git a/composer.json b/composer.json index d33279d..0d7948a 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "appwrite/protocol-proxy", + "name": "utopia-php/proxy", "description": "High-performance protocol-agnostic proxy with Swoole for HTTP, TCP, and SMTP", "type": "library", "license": "BSD-3-Clause", @@ -10,35 +10,46 @@ } ], "require": { - "php": ">=8.2", - "ext-swoole": ">=5.0", + "php": ">=8.4", "ext-redis": "*", - "utopia-php/database": "4.*", + "ext-swoole": ">=6.0", + "utopia-php/console": "*", + "utopia-php/validators": "*" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "phpstan/phpstan": "^1.10", - "laravel/pint": "^1.13" + "phpunit/phpunit": "12.*", + "phpstan/phpstan": "*", + "laravel/pint": "*" }, "autoload": { "psr-4": { - "Appwrite\\ProtocolProxy\\": "src/" + "Utopia\\Proxy\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Utopia\\Tests\\": "tests/" } }, "scripts": { - "test": "phpunit", - "lint": "pint", - "analyse": "phpstan analyse" + "bench:http": "php benchmarks/http.php", + "bench:tcp": "php benchmarks/tcp.php", + "test": "phpunit --testsuite Unit", + "test:integration": "phpunit --testsuite Integration", + "test:all": "phpunit", + "lint": "pint --test --config=pint.json", + "format": "pint --config=pint.json", + "check": "phpstan analyse --level=max --memory-limit=2G src tests" }, "config": { + "php": "8.4", "optimize-autoloader": true, - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d7f1bfa --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +version: '3.8' + +# Development override for docker-compose +# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + http-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug + + tcp-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug + + smtp-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml new file mode 100644 index 0000000..364303c --- /dev/null +++ b/docker-compose.integration.yml @@ -0,0 +1,42 @@ +services: + http-backend: + image: nginx:1.27-alpine + container_name: proxy-http-backend + command: ["sh", "-c", "printf 'server { listen 5678; location / { root /usr/share/nginx/html; index index.html; } }' > /etc/nginx/conf.d/default.conf && echo -n ok > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'"] + networks: + - proxy + + tcp-backend: + image: alpine/socat + container_name: proxy-tcp-backend + command: ["-v", "TCP-LISTEN:15432,reuseaddr,fork", "SYSTEM:cat"] + networks: + - proxy + + smtp-backend: + image: axllent/mailpit:v1.19.0 + container_name: proxy-smtp-backend + command: ["--smtp", "0.0.0.0:1025", "--listen", "0.0.0.0:8025"] + networks: + - proxy + + http-proxy: + environment: + HTTP_BACKEND_ENDPOINT: http-backend:5678 + HTTP_SKIP_VALIDATION: "true" + depends_on: + - http-backend + + tcp-proxy: + environment: + TCP_BACKEND_ENDPOINT: tcp-backend:15432 + TCP_SKIP_VALIDATION: "true" + depends_on: + - tcp-backend + + smtp-proxy: + environment: + SMTP_BACKEND_ENDPOINT: smtp-backend:1025 + SMTP_SKIP_VALIDATION: "true" + depends_on: + - smtp-backend diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b730d49 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,118 @@ +services: + + mariadb: + image: mariadb:11.2 + container_name: proxy-mariadb + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: appwrite + MYSQL_USER: appwrite + MYSQL_PASSWORD: password + ports: + - "${MARIADB_PORT:-3306}:3306" + volumes: + - mariadb_data:/var/lib/mysql + networks: + - proxy + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: proxy-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - proxy + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + http-proxy: + build: . + container_name: proxy-http + restart: unless-stopped + ports: + - "${HTTP_PROXY_PORT:-8080}:8080" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + COMPUTE_API_URL: http://appwrite/v1/compute + COMPUTE_API_KEY: "" + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - proxy + command: php examples/http.php + + tcp-proxy: + build: . + container_name: proxy-tcp + restart: unless-stopped + ports: + - "${TCP_POSTGRES_PORT:-5432}:5432" + - "${TCP_MYSQL_PORT:-3306}:3306" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - proxy + command: php examples/tcp.php + + smtp-proxy: + build: . + container_name: proxy-smtp + restart: unless-stopped + ports: + - "${SMTP_PROXY_PORT:-8025}:25" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - proxy + command: php examples/smtp.php + +networks: + proxy: + driver: bridge + +volumes: + mariadb_data: + redis_data: diff --git a/examples/http-edge-integration.php b/examples/http-edge-integration.php new file mode 100644 index 0000000..7329240 --- /dev/null +++ b/examples/http-edge-integration.php @@ -0,0 +1,165 @@ + */ + private array $connectionCounts = []; + + /** @var array */ + private array $lastActivity = []; + + /** @var int */ + private int $totalRequests = 0; + + /** @var int */ + private int $totalErrors = 0; + + public function resolve(string $resourceId): Result + { + $this->totalRequests++; + + echo "[Resolver] Resolving backend for: {$resourceId}\n"; + + // Validate domain format + if (!preg_match('/^[a-z0-9-]+\.appwrite\.network$/', $resourceId)) { + $this->totalErrors++; + throw new Exception( + "Invalid hostname format: {$resourceId}", + Exception::FORBIDDEN + ); + } + + // Example resolution strategies: + + // Option 1: Kubernetes service discovery (recommended for Edge) + // Extract runtime info and return K8s service + if (preg_match('/^func-([a-z0-9]+)\.appwrite\.network$/', $resourceId, $matches)) { + $functionId = $matches[1]; + $endpoint = "runtime-{$functionId}.runtimes.svc.cluster.local:8080"; + + echo "[Resolver] Resolved to K8s service: {$endpoint}\n"; + + return new Result( + endpoint: $endpoint, + metadata: [ + 'type' => 'function', + 'function_id' => $functionId, + ] + ); + } + + // Option 2: Query database (traditional approach) + // $doc = $db->findOne('functions', [Query::equal('hostname', [$resourceId])]); + // return new Result(endpoint: $doc->getAttribute('endpoint')); + + // Option 3: Query external API (Cloud Platform API) + // $runtime = $edgeApi->getRuntime($resourceId); + // return new Result(endpoint: $runtime['endpoint']); + + // Option 4: Redis cache + fallback + // $endpoint = $redis->get("endpoint:{$resourceId}"); + // if (!$endpoint) { + // $endpoint = $api->resolve($resourceId); + // $redis->setex("endpoint:{$resourceId}", 60, $endpoint); + // } + // return new Result(endpoint: $endpoint); + + $this->totalErrors++; + throw new Exception( + "No backend found for hostname: {$resourceId}", + Exception::NOT_FOUND + ); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connectionCounts[$resourceId] = ($this->connectionCounts[$resourceId] ?? 0) + 1; + $this->lastActivity[$resourceId] = microtime(true); + + echo "[Resolver] Connection opened for: {$resourceId} (active: {$this->connectionCounts[$resourceId]})\n"; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + if (isset($this->connectionCounts[$resourceId])) { + $this->connectionCounts[$resourceId]--; + } + + echo "[Resolver] Connection closed for: {$resourceId}\n"; + + // Example: Log to telemetry, update metrics + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->lastActivity[$resourceId] = microtime(true); + + // Example: Update activity metrics for cold-start detection + } + + public function purge(string $resourceId): void + { + echo "[Resolver] Cache invalidated for: {$resourceId}\n"; + + // Example: Clear Redis cache, notify other workers + } + + public function getStats(): array + { + return [ + 'resolver' => 'edge', + 'total_requests' => $this->totalRequests, + 'total_errors' => $this->totalErrors, + 'active_connections' => array_sum($this->connectionCounts), + 'connections_by_host' => $this->connectionCounts, + ]; + } +}; + +// Create server with custom resolver +$server = new HTTPServer( + $resolver, + host: '0.0.0.0', + port: 8080, + workers: swoole_cpu_num() * 2 +); + +echo "Edge-integrated HTTP Proxy Server\n"; +echo "==================================\n"; +echo "Listening on: http://0.0.0.0:8080\n"; +echo "\nResolver features:\n"; +echo "- resolve: K8s service discovery with domain validation\n"; +echo "- onConnect/onDisconnect: Connection lifecycle tracking\n"; +echo "- track: Activity metrics for cold-start detection\n"; +echo "- getStats: Statistics and telemetry\n\n"; + +$server->start(); diff --git a/examples/http-proxy.php b/examples/http-proxy.php index 013a470..b648ac4 100644 --- a/examples/http-proxy.php +++ b/examples/http-proxy.php @@ -1,62 +1,103 @@ '0.0.0.0', - 'port' => 8080, - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - - // Cold-start settings - 'cold_start_timeout' => 30000, // 30 seconds - 'health_check_interval' => 100, // 100ms - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), +require __DIR__.'/../vendor/autoload.php'; + +use Utopia\Proxy\Resolver; +use Utopia\Proxy\Resolver\Exception; +use Utopia\Proxy\Resolver\Result; +use Utopia\Proxy\Server\HTTP\Swoole as HTTPServer; + +// Simple static mapping of hostnames to backends +$backends = [ + 'api.example.com' => 'localhost:3000', + 'app.example.com' => 'localhost:3001', + 'admin.example.com' => 'localhost:3002', ]; -echo "Starting HTTP Proxy Server...\n"; -echo "Host: {$config['host']}:{$config['port']}\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; +// Create resolver with static backend mapping +$resolver = new class ($backends) implements Resolver { + /** @var array */ + private array $backends; + + /** @var array */ + private array $connectionCounts = []; + + public function __construct(array $backends) + { + $this->backends = $backends; + } + + public function resolve(string $resourceId): Result + { + if (!isset($this->backends[$resourceId])) { + throw new Exception( + "No backend configured for hostname: {$resourceId}", + Exception::NOT_FOUND + ); + } -$server = new HttpServer( - host: $config['host'], - port: $config['port'], - workers: $config['workers'], - config: $config + return new Result(endpoint: $this->backends[$resourceId]); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connectionCounts[$resourceId] = ($this->connectionCounts[$resourceId] ?? 0) + 1; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + if (isset($this->connectionCounts[$resourceId])) { + $this->connectionCounts[$resourceId]--; + } + } + + public function track(string $resourceId, array $metadata = []): void + { + // Track activity for cold-start detection + } + + public function purge(string $resourceId): void + { + // No caching in this simple example + } + + public function getStats(): array + { + return [ + 'resolver' => 'static', + 'backends' => count($this->backends), + 'connections' => $this->connectionCounts, + ]; + } +}; + +// Create server +$server = new HTTPServer( + $resolver, + host: '0.0.0.0', + port: 8080, + workers: swoole_cpu_num() * 2 ); +echo "HTTP Proxy Server\n"; +echo "=================\n"; +echo "Listening on: http://0.0.0.0:8080\n"; +echo "\nConfigured backends:\n"; +foreach ($backends as $hostname => $endpoint) { + echo " {$hostname} -> {$endpoint}\n"; +} +echo "\n"; + $server->start(); diff --git a/examples/http.php b/examples/http.php new file mode 100644 index 0000000..8a96d32 --- /dev/null +++ b/examples/http.php @@ -0,0 +1,186 @@ +endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$config = [ + // Server settings + 'host' => '0.0.0.0', + 'port' => 8080, + 'workers' => $workers, + 'server_mode' => $serverModeValue, + 'reactor_num' => (int) (getenv('HTTP_REACTOR_NUM') ?: (swoole_cpu_num() * 2)), + 'dispatch_mode' => (int) (getenv('HTTP_DISPATCH_MODE') ?: 2), + + // Performance tuning + 'max_connections' => 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB + 'buffer_output_size' => 8 * 1024 * 1024, // 8MB + 'log_level' => SWOOLE_LOG_ERROR, + 'backend_pool_size' => $backendPoolSize, + 'backend_pool_timeout' => 0.001, + 'telemetry_headers' => false, + 'fast_path' => $fastPath, + 'fast_path_assume_ok' => $fastAssumeOk, + 'fixed_backend' => $fixedBackend, + 'direct_response' => $directResponse, + 'direct_response_status' => $directResponseStatus, + 'raw_backend' => $rawBackend, + 'raw_backend_assume_ok' => $rawBackendAssumeOk, + 'http_keepalive_timeout' => $httpKeepaliveTimeout, + 'open_http2_protocol' => $openHttp2, + + // Cold-start settings + 'cold_start_timeout' => 30_000, // 30 seconds + 'health_check_interval' => 100, // 100ms + + // Backend services + 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', + 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', + + // Database connection + 'db_host' => getenv('DB_HOST') ?: 'localhost', + 'db_port' => (int) (getenv('DB_PORT') ?: 3306), + 'db_user' => getenv('DB_USER') ?: 'appwrite', + 'db_pass' => getenv('DB_PASS') ?: 'password', + 'db_name' => getenv('DB_NAME') ?: 'appwrite', + + // Redis cache + 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'redis_port' => (int) (getenv('REDIS_PORT') ?: 6379), + + // Skip SSRF validation for trusted backends (e.g., Docker internal networks) + 'skip_validation' => $skipValidation, +]; + +echo "Starting HTTP Proxy Server...\n"; +echo "Host: {$config['host']}:{$config['port']}\n"; +echo "Workers: {$config['workers']}\n"; +echo "Max connections: {$config['max_connections']}\n"; +echo "Server impl: {$serverImpl}\n"; +echo "\n"; + +$serverClass = $serverImpl === 'swoole' ? HTTPServer::class : HTTPCoroutineServer::class; +$server = new $serverClass( + $resolver, + $config['host'], + $config['port'], + $config['workers'], + $config +); + +$server->start(); diff --git a/examples/smtp-proxy.php b/examples/smtp-proxy.php deleted file mode 100644 index e71b21b..0000000 --- a/examples/smtp-proxy.php +++ /dev/null @@ -1,71 +0,0 @@ - - * RCPT TO: - * DATA - * Subject: Test - * - * Hello World - * . - * QUIT - */ - -$config = [ - // Server settings - 'host' => '0.0.0.0', - 'port' => 25, - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 50000, - 'max_coroutine' => 50000, - - // Cold-start settings - 'cold_start_timeout' => 30000, - 'health_check_interval' => 100, - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), -]; - -echo "Starting SMTP Proxy Server...\n"; -echo "Host: {$config['host']}:{$config['port']}\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; - -$server = new SmtpServer( - host: $config['host'], - port: $config['port'], - workers: $config['workers'], - config: $config -); - -$server->start(); diff --git a/examples/smtp.php b/examples/smtp.php new file mode 100644 index 0000000..ff0a88e --- /dev/null +++ b/examples/smtp.php @@ -0,0 +1,115 @@ + + * RCPT TO: + * DATA + * Subject: Test + * + * Hello World + * . + * QUIT + */ +$backendEndpoint = getenv('SMTP_BACKEND_ENDPOINT') ?: 'smtp-backend:1025'; +$skipValidation = filter_var(getenv('SMTP_SKIP_VALIDATION') ?: 'false', FILTER_VALIDATE_BOOLEAN); + +// Create a simple resolver that returns the configured backend endpoint +$resolver = new class ($backendEndpoint) implements Resolver { + public function __construct(private string $endpoint) + { + } + + public function resolve(string $resourceId): Result + { + return new Result(endpoint: $this->endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$config = [ + // Server settings + 'host' => '0.0.0.0', + 'port' => 25, + 'workers' => swoole_cpu_num() * 2, + + // Performance tuning + 'max_connections' => 100000, + 'max_coroutine' => 100000, + 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB + 'buffer_output_size' => 8 * 1024 * 1024, // 8MB + 'log_level' => SWOOLE_LOG_ERROR, + + // Cold-start settings + 'cold_start_timeout' => 30000, + 'health_check_interval' => 100, + + // Backend services + 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', + 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', + + // Database connection + 'db_host' => getenv('DB_HOST') ?: 'localhost', + 'db_port' => (int) (getenv('DB_PORT') ?: 3306), + 'db_user' => getenv('DB_USER') ?: 'appwrite', + 'db_pass' => getenv('DB_PASS') ?: 'password', + 'db_name' => getenv('DB_NAME') ?: 'appwrite', + + // Redis cache + 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'redis_port' => (int) (getenv('REDIS_PORT') ?: 6379), + + // Skip SSRF validation for trusted backends (e.g., Docker internal networks) + 'skip_validation' => $skipValidation, +]; + +echo "Starting SMTP Proxy Server...\n"; +echo "Host: {$config['host']}:{$config['port']}\n"; +echo "Workers: {$config['workers']}\n"; +echo "Max connections: {$config['max_connections']}\n"; +echo "\n"; + +$server = new SMTPServer( + $resolver, + $config['host'], + $config['port'], + $config['workers'], + $config +); + +$server->start(); diff --git a/examples/tcp-proxy.php b/examples/tcp-proxy.php deleted file mode 100644 index 0c1d324..0000000 --- a/examples/tcp-proxy.php +++ /dev/null @@ -1,68 +0,0 @@ - '0.0.0.0', - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB for database traffic - - // Cold-start settings - 'cold_start_timeout' => 30000, - 'health_check_interval' => 100, - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), -]; - -$ports = [5432, 3306]; // PostgreSQL, MySQL - -echo "Starting TCP Proxy Server...\n"; -echo "Host: {$config['host']}\n"; -echo "Ports: " . implode(', ', $ports) . "\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; - -$server = new TcpServer( - host: $config['host'], - ports: $ports, - workers: $config['workers'], - config: $config -); - -$server->start(); diff --git a/examples/tcp.php b/examples/tcp.php new file mode 100644 index 0000000..62f355c --- /dev/null +++ b/examples/tcp.php @@ -0,0 +1,149 @@ +endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$postgresPort = $envInt('TCP_POSTGRES_PORT', 5432); +$mysqlPort = $envInt('TCP_MYSQL_PORT', 3306); +$ports = array_values(array_filter([$postgresPort, $mysqlPort], static fn (int $port): bool => $port > 0)); +if ($ports === []) { + $ports = [5432, 3306]; +} + +$config = new TCPConfig( + host: '0.0.0.0', + ports: $ports, + workers: $workers, + reactorNum: $reactorNum, + dispatchMode: $dispatchMode, + skipValidation: $skipValidation, + tls: $tls, +); + +echo "Starting TCP Proxy Server...\n"; +echo "Host: {$config->host}\n"; +echo 'Ports: '.implode(', ', $config->ports)."\n"; +echo "Workers: {$config->workers}\n"; +echo "Max connections: {$config->maxConnections}\n"; +echo "Server impl: {$serverImpl}\n"; +if ($tls !== null) { + echo "TLS: enabled (certificate: {$tls->certificate})\n"; + if ($tls->isMutual()) { + echo "mTLS: enabled (ca: {$tls->ca})\n"; + } +} +echo "\n"; + +$serverClass = $serverImpl === 'swoole' ? TCPServer::class : TCPCoroutineServer::class; +$server = new $serverClass($resolver, $config); + +$server->start(); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..090a56f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + tests/Integration + + + tests/Integration + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..14614b0 --- /dev/null +++ b/pint.json @@ -0,0 +1,21 @@ +{ + "preset": "psr12", + "exclude": [ + "./app/sdks", + "./tests/resources/functions", + "./app/console" + ], + "rules": { + "array_indentation": true, + "single_import_per_statement": true, + "simplified_null_return": true, + "ordered_imports": { + "sort_algorithm": "alpha", + "imports_order": [ + "const", + "class", + "function" + ] + } + } +} diff --git a/src/Adapter.php b/src/Adapter.php new file mode 100644 index 0000000..627f5c7 --- /dev/null +++ b/src/Adapter.php @@ -0,0 +1,373 @@ + Connection pool stats */ + protected array $stats = [ + 'connections' => 0, + 'cacheHits' => 0, + 'cacheMisses' => 0, + 'routingErrors' => 0, + ]; + + /** @var bool Skip SSRF validation for trusted backends */ + protected bool $skipValidation = false; + + /** @var int Routing cache TTL in seconds (0 disables caching) */ + protected int $cacheTTL = 0; + + /** @var int Activity tracking interval in seconds */ + protected int $interval = 30; + + /** @var array Last activity timestamp per resource */ + protected array $lastActivity = []; + + /** @var array */ + protected array $bytes = []; + + /** @var \Closure|null Custom resolve callback, checked before the resolver */ + protected ?\Closure $callback = null; + + public function __construct( + public ?Resolver $resolver = null { + get { + return $this->resolver; + } + }, + protected Protocol $protocol = Protocol::TCP, + ) { + $this->initRouter(); + } + + /** + * Set activity tracking interval + */ + public function setInterval(int $seconds): static + { + $this->interval = $seconds; + + return $this; + } + + /** + * Set a custom resolve callback that is checked before the resolver + * + * The callback receives a resource ID and should return a Resolver\Result. + */ + public function onResolve(callable $callback): static + { + $this->callback = $callback(...); + + return $this; + } + + /** + * Skip SSRF validation for trusted backends + */ + public function setSkipValidation(bool $skip): static + { + $this->skipValidation = $skip; + + return $this; + } + + public function setCacheTTL(int $seconds): static + { + $this->cacheTTL = $seconds; + + return $this; + } + + /** + * Notify connect event + * + * @param array $metadata Additional connection metadata + */ + public function notifyConnect(string $resourceId, array $metadata = []): void + { + $this->resolver?->onConnect($resourceId, $metadata); + } + + /** + * Notify close event + * + * @param array $metadata Additional disconnection metadata + */ + public function notifyClose(string $resourceId, array $metadata = []): void + { + if (isset($this->bytes[$resourceId])) { + $metadata['inboundBytes'] = $this->bytes[$resourceId]->inbound; + $metadata['outboundBytes'] = $this->bytes[$resourceId]->outbound; + unset($this->bytes[$resourceId]); + } + + $this->resolver?->onDisconnect($resourceId, $metadata); + unset($this->lastActivity[$resourceId]); + } + + /** + * Record bytes transferred for a resource + */ + public function recordBytes( + string $resourceId, + int $inbound = 0, + int $outbound = 0, + ): void { + if (!isset($this->bytes[$resourceId])) { + $this->bytes[$resourceId] = new Bytes(); + } + + $this->bytes[$resourceId]->inbound += $inbound; + $this->bytes[$resourceId]->outbound += $outbound; + } + + /** + * @param array $metadata Activity metadata + */ + public function track(string $resourceId, array $metadata = []): void + { + $now = time(); + $lastUpdate = $this->lastActivity[$resourceId] ?? 0; + + if (($now - $lastUpdate) < $this->interval) { + return; + } + + $this->lastActivity[$resourceId] = $now; + + if (isset($this->bytes[$resourceId])) { + $metadata['inboundBytes'] = $this->bytes[$resourceId]->inbound; + $metadata['outboundBytes'] = $this->bytes[$resourceId]->outbound; + $this->bytes[$resourceId] = new Bytes(); + } + + $this->resolver?->track($resourceId, $metadata); + } + + /** + * Get protocol type + */ + public function getProtocol(): Protocol + { + return $this->protocol; + } + + /** + * Route connection to backend + * + * @param string $resourceId Protocol-specific identifier + * @return ConnectionResult Backend endpoint and metadata + * + * @throws ResolverException If routing fails + */ + public function route(string $resourceId): ConnectionResult + { + $now = \time(); + + if ($this->cacheTTL > 0) { + $cached = $this->router->get($resourceId); + + if ($cached !== false && \is_array($cached)) { + /** @var array{endpoint: string, updated: int} $cached */ + if (($now - $cached['updated']) < $this->cacheTTL) { + $this->stats['cacheHits']++; + $this->stats['connections']++; + + return new ConnectionResult( + endpoint: $cached['endpoint'], + protocol: $this->getProtocol(), + metadata: ['cached' => true] + ); + } + } + } + + $this->stats['cacheMisses']++; + + try { + if ($this->callback !== null) { + $resolved = ($this->callback)($resourceId); + if ($resolved instanceof Resolver\Result) { + $result = $resolved; + } elseif (\is_string($resolved)) { + $result = new Resolver\Result(endpoint: $resolved); + } else { + throw new ResolverException( + 'Resolve callback must return Result or string', + ResolverException::INTERNAL + ); + } + } elseif ($this->resolver !== null) { + $result = $this->resolver->resolve($resourceId); + } else { + throw new ResolverException( + "No resolver or resolve callback configured", + ResolverException::NOT_FOUND + ); + } + $endpoint = $result->endpoint; + + if (empty($endpoint)) { + throw new ResolverException( + "Resolver returned empty endpoint for: {$resourceId}", + ResolverException::NOT_FOUND + ); + } + + if (!$this->skipValidation) { + $endpoint = $this->validate($endpoint); + } + + if ($this->cacheTTL > 0) { + $this->router->set($resourceId, [ + 'endpoint' => $endpoint, + 'updated' => $now, + ]); + } + + $this->stats['connections']++; + + return new ConnectionResult( + endpoint: $endpoint, + protocol: $this->getProtocol(), + metadata: \array_merge(['cached' => false], $result->metadata) + ); + } catch (\Exception $e) { + $this->stats['routingErrors']++; + throw $e; + } + } + + /** + * Validate backend endpoint to prevent SSRF attacks. + * + * Returns the validated endpoint with the hostname replaced by the + * resolved IP address to prevent DNS rebinding (TOCTOU) attacks. + */ + protected function validate(string $endpoint): string + { + $parts = \explode(':', $endpoint); + if (\count($parts) > 2) { + throw new ResolverException("Invalid endpoint format: {$endpoint}"); + } + + $host = $parts[0]; + $hasPort = isset($parts[1]); + $port = $hasPort ? (int) $parts[1] : 0; + + if ($hasPort && !(new Range(1, 65535))->isValid($port)) { + throw new ResolverException("Invalid port number: {$port}"); + } + + $ip = \gethostbyname($host); + if ($ip === $host && !(new IP())->isValid($ip)) { + throw new ResolverException("Cannot resolve hostname: {$host}"); + } + + if ((new IP(IP::V4))->isValid($ip)) { + $longIp = \ip2long($ip); + if ($longIp === false) { + throw new ResolverException("Invalid IP address: {$ip}"); + } + + $blockedRanges = [ + ['10.0.0.0', '10.255.255.255'], + ['172.16.0.0', '172.31.255.255'], + ['192.168.0.0', '192.168.255.255'], + ['127.0.0.0', '127.255.255.255'], + ['169.254.0.0', '169.254.255.255'], + ['224.0.0.0', '239.255.255.255'], + ['240.0.0.0', '255.255.255.255'], + ['0.0.0.0', '0.255.255.255'], + ]; + + foreach ($blockedRanges as [$rangeStart, $rangeEnd]) { + $rangeStartLong = \ip2long($rangeStart); + $rangeEndLong = \ip2long($rangeEnd); + if ($longIp >= $rangeStartLong && $longIp <= $rangeEndLong) { + throw new ResolverException("Access to private/reserved IP address is forbidden: {$ip}"); + } + } + } elseif ((new IP(IP::V6))->isValid($ip)) { + if ( + $ip === '::1' + || \str_starts_with($ip, 'fe80:') + || \str_starts_with($ip, 'fc00:') + || \str_starts_with($ip, 'fd00:') + || \str_starts_with(\strtolower($ip), '::ffff:') + ) { + throw new ResolverException("Access to private/reserved IPv6 address is forbidden: {$ip}"); + } + } + + return $hasPort ? "{$ip}:{$port}" : $ip; + } + + /** + * Initialize routing cache table + */ + protected function initRouter(int $size = 10_000): void + { + $this->router = new Table($size); + $this->router->column('endpoint', Table::TYPE_STRING, 256); + $this->router->column('updated', Table::TYPE_INT, 8); + $this->router->create(); + } + + /** + * Parse an endpoint string into host and port. + * + * If the endpoint already contains a port, that port is used. + * Otherwise the provided default port is used. + * + * @return array{0: string, 1: int} + */ + public static function parseEndpoint(string $endpoint, int $defaultPort): array + { + $parts = \explode(':', $endpoint, 2); + $host = $parts[0]; + $port = isset($parts[1]) && $parts[1] !== '' ? (int) $parts[1] : $defaultPort; + + return [$host, $port]; + } + + /** + * Get routing and connection stats + * + * @return array + */ + public function getStats(): array + { + $totalRequests = $this->stats['cacheHits'] + $this->stats['cacheMisses']; + + return [ + 'adapter' => (new \ReflectionClass($this))->getShortName(), + 'protocol' => $this->getProtocol()->value, + 'connections' => $this->stats['connections'], + 'cacheHits' => $this->stats['cacheHits'], + 'cacheMisses' => $this->stats['cacheMisses'], + 'cacheHitRate' => $totalRequests > 0 + ? \round($this->stats['cacheHits'] / $totalRequests * 100, 2) + : 0, + 'routingErrors' => $this->stats['routingErrors'], + 'routingTableMemory' => $this->router->memorySize, + 'routingTableSize' => $this->router->count(), + 'resolver' => $this->resolver?->getStats() ?? [], + ]; + } +} diff --git a/src/Adapter/TCP.php b/src/Adapter/TCP.php new file mode 100644 index 0000000..a6d1e11 --- /dev/null +++ b/src/Adapter/TCP.php @@ -0,0 +1,141 @@ + */ + protected array $connections = []; + + protected float $timeout = 30.0; + + protected float $connectTimeout = 5.0; + + public function __construct( + public int $port { + get { + return $this->port; + } + }, + ?Resolver $resolver = null, + ) { + parent::__construct($resolver); + } + + public function setTimeout(float $timeout): static + { + $this->timeout = $timeout; + + return $this; + } + + public function setConnectTimeout(float $timeout): static + { + $this->connectTimeout = $timeout; + + return $this; + } + + /** + * Get protocol type + */ + public function getProtocol(): Protocol + { + return match ($this->port) { + 5432 => Protocol::PostgreSQL, + 3306 => Protocol::MySQL, + 27017 => Protocol::MongoDB, + 6379 => Protocol::Redis, + 11211 => Protocol::Memcached, + 9092 => Protocol::Kafka, + 5672 => Protocol::AMQP, + 9000 => Protocol::ClickHouse, + 9042 => Protocol::Cassandra, + 4222 => Protocol::NATS, + 1433 => Protocol::MSSQL, + 1521 => Protocol::Oracle, + 9200 => Protocol::Elasticsearch, + 1883 => Protocol::MQTT, + 50051 => Protocol::GRPC, + 2181 => Protocol::ZooKeeper, + 2379 => Protocol::Etcd, + 7687 => Protocol::Neo4j, + 11210 => Protocol::Couchbase, + 26257 => Protocol::CockroachDB, + 4000 => Protocol::TiDB, + 6650 => Protocol::Pulsar, + 21 => Protocol::FTP, + 389 => Protocol::LDAP, + 28015 => Protocol::RethinkDB, + default => Protocol::TCP, + }; + } + + /** + * Get or create backend connection for a client. + * + * On first call for a given fd, routes via the resolver and establishes the + * backend connection. Subsequent calls return the cached connection. + * + * @throws \Exception + */ + public function getConnection(string $data, int $fd): Client + { + if (isset($this->connections[$fd])) { + return $this->connections[$fd]; + } + + $result = $this->route($data); + + [$host, $port] = self::parseEndpoint($result->endpoint, $this->port); + + $client = new Client(SWOOLE_SOCK_TCP); + + $client->set([ + 'timeout' => $this->timeout, + 'connect_timeout' => $this->connectTimeout, + 'open_tcp_nodelay' => true, + 'socket_buffer_size' => 2 * 1024 * 1024, + ]); + + if (!$client->connect($host, $port, $this->connectTimeout)) { + throw new \Exception("Failed to connect to backend: {$host}:{$port}"); + } + + $this->connections[$fd] = $client; + + return $client; + } + + /** + * Close backend connection for a client + */ + public function closeConnection(int $fd): void + { + if (isset($this->connections[$fd])) { + $this->connections[$fd]->close(); + unset($this->connections[$fd]); + } + } + +} diff --git a/src/Bytes.php b/src/Bytes.php new file mode 100644 index 0000000..7d09552 --- /dev/null +++ b/src/Bytes.php @@ -0,0 +1,10 @@ + 0, - 'cold_starts' => 0, - 'cache_hits' => 0, - 'cache_misses' => 0, - ]; - - public function __construct( - Cache $cache, - Group $dbPool, - string $computeApiUrl, - string $computeApiKey, - int $coldStartTimeout = 30_000, - int $healthCheckInterval = 100 - ) { - $this->cache = $cache; - $this->dbPool = $dbPool; - $this->computeApiUrl = $computeApiUrl; - $this->computeApiKey = $computeApiKey; - $this->coldStartTimeout = $coldStartTimeout; - $this->healthCheckInterval = $healthCheckInterval; - - // Initialize shared memory table for ultra-fast lookups - $this->initStatusTable(); - } - - /** - * Initialize Swoole shared memory table - * 100k entries = ~10MB memory, O(1) lookups - */ - protected function initStatusTable(): void - { - $this->statusTable = new \Swoole\Table(100_000); - $this->statusTable->column('status', \Swoole\Table::TYPE_STRING, 16); - $this->statusTable->column('endpoint', \Swoole\Table::TYPE_STRING, 64); - $this->statusTable->column('updated', \Swoole\Table::TYPE_INT, 8); - $this->statusTable->create(); - } - - /** - * Main connection handling flow - FAST AS FUCK - * - * Performance: <1ms for cache hit, <100ms for cold-start - */ - public function handleConnection(string $resourceId): ConnectionResult - { - $startTime = microtime(true); - - // 1. Check shared memory first (fastest path - O(1)) - $cached = $this->statusTable->get($resourceId); - if ($cached && (time() - $cached['updated']) < 1) { - $this->stats['cache_hits']++; - - if ($cached['status'] === ResourceStatus::ACTIVE) { - return new ConnectionResult( - endpoint: $cached['endpoint'], - protocol: $this->getProtocol(), - metadata: ['cached' => true, 'latency_ms' => round((microtime(true) - $startTime) * 1000, 2)] - ); - } - } - - $this->stats['cache_misses']++; - - // 2. Identify target resource (database lookup via connection pool) - $resource = $this->identifyResource($resourceId); - - // 3. Check resource status - $status = $this->getResourceStatus($resource); - - // 4. If inactive, trigger cold-start (async coroutine) - if ($status === ResourceStatus::INACTIVE) { - $this->stats['cold_starts']++; - $this->triggerColdStart($resource); - $this->waitForReady($resource); - } - - // 5. Get connection endpoint - $endpoint = $this->getEndpoint($resource); - - // 6. Update shared memory cache - $this->statusTable->set($resourceId, [ - 'status' => ResourceStatus::ACTIVE, - 'endpoint' => $endpoint, - 'updated' => time(), - ]); - - $this->stats['connections']++; - - return new ConnectionResult( - endpoint: $endpoint, - protocol: $this->getProtocol(), - metadata: [ - 'cached' => false, - 'cold_start' => $status === ResourceStatus::INACTIVE, - 'latency_ms' => round((microtime(true) - $startTime) * 1000, 2) - ] - ); - } - - /** - * Protocol-specific implementations must override these - */ - abstract protected function identifyResource(string $resourceId): Resource; - abstract protected function getProtocol(): string; - - /** - * Get resource status with aggressive caching - * - * Performance: <1ms with cache, <10ms without - */ - protected function getResourceStatus(Resource $resource): string - { - // Check Redis cache first - $cacheKey = "container:status:{$resource->id}"; - $cached = $this->cache->load($cacheKey); - - if ($cached !== null && $cached !== false) { - return $cached; - } - - // Query database via connection pool - $db = $this->dbPool->get(); - try { - $doc = $db->getDocument('containers', $resource->containerId); - $status = $doc->getAttribute('status', ResourceStatus::INACTIVE); - - // Cache for 1 second (balance freshness vs performance) - $this->cache->save($cacheKey, $status, 1); - - return $status; - } finally { - $this->dbPool->put($db); - } - } - - /** - * Trigger cold-start via Compute API (async coroutine) - * - * Performance: Non-blocking, returns immediately - */ - protected function triggerColdStart(Resource $resource): void - { - // Use Swoole HTTP client for async requests - Coroutine::create(function () use ($resource) { - $client = new \Swoole\Coroutine\Http\Client( - parse_url($this->computeApiUrl, PHP_URL_HOST), - parse_url($this->computeApiUrl, PHP_URL_PORT) ?? 80 - ); - - $client->setHeaders([ - 'Authorization' => 'Bearer ' . $this->computeApiKey, - 'Content-Type' => 'application/json', - ]); - - $client->set(['timeout' => 5]); - - $client->post( - "/containers/{$resource->containerId}/start", - json_encode(['resourceId' => $resource->id]) - ); - - $client->close(); - }); - } - - /** - * Wait for container to become ready - * - * Performance: <100ms for warm pool, <30s for cold-start - */ - protected function waitForReady(Resource $resource): void - { - $startTime = microtime(true); - $channel = new Channel(1); - - // Health check in coroutine - Coroutine::create(function () use ($resource, $channel, $startTime) { - while ((microtime(true) - $startTime) * 1000 < $this->coldStartTimeout) { - $status = $this->getResourceStatus($resource); - - if ($status === ResourceStatus::ACTIVE) { - $channel->push(true); - return; - } - - Coroutine::sleep($this->healthCheckInterval / 1000); - } - - $channel->push(false); - }); - - $ready = $channel->pop($this->coldStartTimeout / 1000); - - if (!$ready) { - throw new \Exception("Cold-start timeout after {$this->coldStartTimeout}ms"); - } - } - - /** - * Get connection endpoint from database - * - * Performance: <10ms with connection pooling - */ - protected function getEndpoint(Resource $resource): string - { - $db = $this->dbPool->get(); - try { - $doc = $db->getDocument('containers', $resource->containerId); - return $doc->getAttribute('internalIP'); - } finally { - $this->dbPool->put($db); - } - } - - /** - * Get connection stats for monitoring - */ - public function getStats(): array - { - return array_merge($this->stats, [ - 'cache_hit_rate' => $this->stats['cache_hits'] + $this->stats['cache_misses'] > 0 - ? round($this->stats['cache_hits'] / ($this->stats['cache_hits'] + $this->stats['cache_misses']) * 100, 2) - : 0, - 'status_table_memory' => $this->statusTable->memorySize, - 'status_table_size' => $this->statusTable->count(), - ]); - } -} diff --git a/src/ConnectionResult.php b/src/ConnectionResult.php index 6cf1ff5..449e1ae 100644 --- a/src/ConnectionResult.php +++ b/src/ConnectionResult.php @@ -1,15 +1,19 @@ $metadata + */ public function __construct( - public string $endpoint, - public string $protocol, - public array $metadata = [] - ) {} + public private(set) string $endpoint, + public private(set) Protocol $protocol, + public private(set) array $metadata = [] + ) { + } } diff --git a/src/Http/HttpConnectionManager.php b/src/Http/HttpConnectionManager.php deleted file mode 100644 index 0632bf0..0000000 --- a/src/Http/HttpConnectionManager.php +++ /dev/null @@ -1,46 +0,0 @@ -dbPool->get(); - - try { - $doc = $db->findOne('functions', [ - Query::equal('hostname', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("Function not found for hostname: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'function', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return 'http'; - } -} diff --git a/src/Http/HttpServer.php b/src/Http/HttpServer.php deleted file mode 100644 index 3c4164e..0000000 --- a/src/Http/HttpServer.php +++ /dev/null @@ -1,229 +0,0 @@ -config = array_merge([ - 'host' => $host, - 'port' => $port, - 'workers' => $workers, - 'max_connections' => 100_000, - 'max_coroutine' => 100_000, - 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB - 'buffer_output_size' => 2 * 1024 * 1024, // 2MB - 'enable_coroutine' => true, - 'max_wait_time' => 60, - ], $config); - - $this->server = new Server($host, $port, SWOOLE_PROCESS); - $this->configure(); - } - - protected function configure(): void - { - $this->server->set([ - 'worker_num' => $this->config['workers'], - 'max_connection' => $this->config['max_connections'], - 'max_coroutine' => $this->config['max_coroutine'], - 'socket_buffer_size' => $this->config['socket_buffer_size'], - 'buffer_output_size' => $this->config['buffer_output_size'], - 'enable_coroutine' => $this->config['enable_coroutine'], - 'max_wait_time' => $this->config['max_wait_time'], - - // Performance tuning - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - - // Enable stats - 'task_enable_coroutine' => true, - ]); - - $this->server->on('start', $this->onStart(...)); - $this->server->on('workerStart', $this->onWorkerStart(...)); - $this->server->on('request', $this->onRequest(...)); - } - - public function onStart(Server $server): void - { - echo "HTTP Proxy Server started at http://{$this->config['host']}:{$this->config['port']}\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; - } - - public function onWorkerStart(Server $server, int $workerId): void - { - // Initialize connection manager per worker - $this->manager = new HttpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100 - ); - - echo "Worker #{$workerId} started\n"; - } - - /** - * Main request handler - FAST AS FUCK - * - * Performance: <1ms for cache hit - */ - public function onRequest(Request $request, Response $response): void - { - $startTime = microtime(true); - - try { - // Extract hostname from request - $hostname = $request->header['host'] ?? null; - - if (!$hostname) { - $response->status(400); - $response->end('Missing Host header'); - return; - } - - // Handle connection routing - $result = $this->manager->handleConnection($hostname); - - // Forward request to backend (zero-copy where possible) - $this->forwardRequest($request, $response, $result->endpoint); - - // Add telemetry headers - $latency = round((microtime(true) - $startTime) * 1000, 2); - $response->header('X-Proxy-Latency-Ms', (string)$latency); - $response->header('X-Proxy-Protocol', $result->protocol); - - if (isset($result->metadata['cached'])) { - $response->header('X-Proxy-Cache', $result->metadata['cached'] ? 'HIT' : 'MISS'); - } - - } catch (\Exception $e) { - $response->status(503); - $response->header('Content-Type', 'application/json'); - $response->end(json_encode([ - 'error' => 'Service Unavailable', - 'message' => $e->getMessage(), - ])); - } - } - - /** - * Forward HTTP request to backend using Swoole HTTP client - * - * Performance: Zero-copy streaming for large responses - */ - protected function forwardRequest(Request $request, Response $response, string $endpoint): void - { - [$host, $port] = explode(':', $endpoint . ':80'); - $port = (int)$port; - - $client = new \Swoole\Coroutine\Http\Client($host, $port); - - // Set timeout - $client->set([ - 'timeout' => 30, - 'keep_alive' => true, - ]); - - // Forward headers - $headers = []; - foreach ($request->header as $key => $value) { - if (!in_array(strtolower($key), ['host', 'connection'])) { - $headers[$key] = $value; - } - } - $client->setHeaders($headers); - - // Forward cookies - if (!empty($request->cookie)) { - $client->setCookies($request->cookie); - } - - // Forward request body - $body = $request->getContent() ?: ''; - - // Make request - $method = strtolower($request->server['request_method']); - $path = $request->server['request_uri']; - - $client->$method($path, $body); - - // Forward response - $response->status($client->statusCode); - - // Forward response headers - if (!empty($client->headers)) { - foreach ($client->headers as $key => $value) { - $response->header($key, $value); - } - } - - // Forward response cookies - if (!empty($client->set_cookie_headers)) { - foreach ($client->set_cookie_headers as $cookie) { - $response->header('Set-Cookie', $cookie); - } - } - - // Forward response body - $response->end($client->body); - - $client->close(); - } - - protected function initCache(): \Utopia\Cache\Cache - { - $adapter = new \Utopia\Cache\Adapter\Redis( - new \Redis() - ); - - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); - } - - public function start(): void - { - $this->server->start(); - } - - public function getStats(): array - { - return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'requests' => $this->server->stats()['request_count'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'manager' => $this->manager?->getStats() ?? [], - ]; - } -} diff --git a/src/Protocol.php b/src/Protocol.php new file mode 100644 index 0000000..2cc1da1 --- /dev/null +++ b/src/Protocol.php @@ -0,0 +1,35 @@ + $metadata Activity metadata + */ + public function track(string $resourceId, array $metadata = []): void; + + /** + * Invalidate cached resolution data for a resource + * + * @param string $resourceId The resource identifier + */ + public function purge(string $resourceId): void; + + /** + * Get resolver statistics + * + * @return array Statistics data + */ + public function getStats(): array; + + /** + * Called when a new connection is established + * + * @param string $resourceId The resource identifier + * @param array $metadata Additional connection metadata + */ + public function onConnect(string $resourceId, array $metadata = []): void; + + /** + * Called when a connection is closed + * + * @param string $resourceId The resource identifier + * @param array $metadata Additional disconnection metadata + */ + public function onDisconnect(string $resourceId, array $metadata = []): void; +} diff --git a/src/Resolver/Exception.php b/src/Resolver/Exception.php new file mode 100644 index 0000000..e903b59 --- /dev/null +++ b/src/Resolver/Exception.php @@ -0,0 +1,30 @@ + $context + */ + public function __construct( + string $message, + int $code = self::INTERNAL, + public readonly array $context = [] + ) { + parent::__construct($message, $code); + } +} diff --git a/src/Resolver/Result.php b/src/Resolver/Result.php new file mode 100644 index 0000000..ca5a67f --- /dev/null +++ b/src/Resolver/Result.php @@ -0,0 +1,21 @@ + $metadata Optional metadata about the resolved backend + * @param int|null $timeout Optional connection timeout override in seconds + */ + public function __construct( + public string $endpoint, + public array $metadata = [], + public ?int $timeout = null + ) { + } +} diff --git a/src/Resource.php b/src/Resource.php deleted file mode 100644 index 5a81874..0000000 --- a/src/Resource.php +++ /dev/null @@ -1,17 +0,0 @@ -reactorNum = $reactorNum ?? swoole_cpu_num() * 2; + } +} diff --git a/src/Server/HTTP/Swoole.php b/src/Server/HTTP/Swoole.php new file mode 100644 index 0000000..7eb1973 --- /dev/null +++ b/src/Server/HTTP/Swoole.php @@ -0,0 +1,120 @@ +start(); + * ``` + */ +class Swoole +{ + use Handler; + + protected Server $server; + + public function __construct( + protected ?Resolver $resolver = null, + ?Config $config = null, + ) { + $this->config = $config ?? new Config(); + $this->server = new Server( + $this->config->host, + $this->config->port, + $this->config->serverMode, + ); + $this->configure(); + } + + protected function configure(): void + { + $this->server->set([ + 'worker_num' => $this->config->workers, + 'reactor_num' => $this->config->reactorNum, + 'max_connection' => $this->config->maxConnections, + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'buffer_output_size' => $this->config->bufferOutputSize, + 'enable_coroutine' => $this->config->enableCoroutine, + 'max_wait_time' => $this->config->maxWaitTime, + 'open_http_protocol' => $this->config->httpProtocol, + 'open_http2_protocol' => $this->config->http2Protocol, + 'http_keepalive_timeout' => $this->config->keepaliveTimeout, + 'max_request' => $this->config->maxRequest, + 'dispatch_mode' => $this->config->dispatchMode, + 'enable_reuse_port' => $this->config->enableReusePort, + 'backlog' => $this->config->backlog, + 'http_parse_post' => $this->config->parsePost, + 'http_parse_cookie' => $this->config->parseCookie, + 'http_parse_files' => $this->config->parseFiles, + 'http_compression' => $this->config->compression, + 'log_level' => $this->config->logLevel, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + 'task_enable_coroutine' => true, + ]); + + $this->server->on(Constant::EVENT_START, $this->onStart(...)); + $this->server->on(Constant::EVENT_WORKER_START, $this->onWorkerStart(...)); + $this->server->on(Constant::EVENT_REQUEST, $this->onRequest(...)); + } + + public function onStart(Server $server): void + { + Console::success("HTTP Proxy Server started at http://{$this->config->host}:{$this->config->port}"); + Console::log("Workers: {$this->config->workers}"); + Console::log("Max connections: {$this->config->maxConnections}"); + } + + public function onWorkerStart(Server $server, int $workerId): void + { + $this->adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $this->adapter->setCacheTTL($this->config->cacheTTL); + + if ($this->config->skipValidation) { + $this->adapter->setSkipValidation(true); + } + + if ($this->config->workerStart !== null) { + ($this->config->workerStart)($server, $workerId, $this->adapter); + } + + Console::log("Worker #{$workerId} started ({$this->adapter->getProtocol()->value})"); + } + + public function start(): void + { + $this->server->start(); + } + + /** + * @return array + */ + public function getStats(): array + { + /** @var array $stats */ + $stats = $this->server->stats(); + + return [ + 'connections' => $stats['connection_num'] ?? 0, + 'requests' => $stats['request_count'] ?? 0, + 'workers' => $stats['worker_num'] ?? 0, + 'adapter' => $this->adapter->getStats(), + ]; + } +} diff --git a/src/Server/HTTP/Swoole/Coroutine.php b/src/Server/HTTP/Swoole/Coroutine.php new file mode 100644 index 0000000..300ca27 --- /dev/null +++ b/src/Server/HTTP/Swoole/Coroutine.php @@ -0,0 +1,131 @@ +start(); + * ``` + */ +class Coroutine +{ + use Handler; + + protected CoroutineServer $server; + + public function __construct( + protected ?Resolver $resolver = null, + ?Config $config = null, + ) { + $this->config = $config ?? new Config(); + $this->initAdapter(); + $this->server = new CoroutineServer( + $this->config->host, + $this->config->port, + false, + $this->config->enableReusePort, + ); + $this->configure(); + } + + protected function configure(): void + { + $this->server->set([ + 'worker_num' => $this->config->workers, + 'reactor_num' => $this->config->reactorNum, + 'max_connection' => $this->config->maxConnections, + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'buffer_output_size' => $this->config->bufferOutputSize, + 'enable_coroutine' => $this->config->enableCoroutine, + 'max_wait_time' => $this->config->maxWaitTime, + 'open_http_protocol' => $this->config->httpProtocol, + 'open_http2_protocol' => $this->config->http2Protocol, + 'http_keepalive_timeout' => $this->config->keepaliveTimeout, + 'max_request' => $this->config->maxRequest, + 'dispatch_mode' => $this->config->dispatchMode, + 'enable_reuse_port' => $this->config->enableReusePort, + 'backlog' => $this->config->backlog, + 'http_parse_post' => $this->config->parsePost, + 'http_parse_cookie' => $this->config->parseCookie, + 'http_parse_files' => $this->config->parseFiles, + 'http_compression' => $this->config->compression, + 'log_level' => $this->config->logLevel, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + 'task_enable_coroutine' => true, + ]); + $this->server->handle('/', $this->onRequest(...)); + } + + protected function initAdapter(): void + { + $this->adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $this->adapter->setCacheTTL($this->config->cacheTTL); + + if ($this->config->skipValidation) { + $this->adapter->setSkipValidation(true); + } + } + + public function onStart(): void + { + Console::success("HTTP Proxy Server started at http://{$this->config->host}:{$this->config->port}"); + Console::log("Workers: {$this->config->workers}"); + Console::log("Max connections: {$this->config->maxConnections}"); + } + + public function onWorkerStart(int $workerId = 0): void + { + if ($this->config->workerStart !== null) { + ($this->config->workerStart)(null, $workerId, $this->adapter); + } + + Console::log("Worker #{$workerId} started ({$this->adapter->getProtocol()->value})"); + } + + public function start(): void + { + if (SwooleCoroutine::getCid() > 0) { + $this->onStart(); + $this->onWorkerStart(0); + $this->server->start(); + + return; + } + + SwooleCoroutine\run(function (): void { + $this->onStart(); + $this->onWorkerStart(0); + $this->server->start(); + }); + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'connections' => 0, + 'requests' => 0, + 'workers' => 1, + 'adapter' => $this->adapter->getStats(), + ]; + } +} diff --git a/src/Server/HTTP/Swoole/Handler.php b/src/Server/HTTP/Swoole/Handler.php new file mode 100644 index 0000000..c1fc828 --- /dev/null +++ b/src/Server/HTTP/Swoole/Handler.php @@ -0,0 +1,387 @@ + */ + protected array $pools = []; + + public function onRequest(Request $request, Response $response): void + { + if ($this->config->requestHandler !== null) { + try { + ($this->config->requestHandler)($request, $response, $this->adapter); + } catch (\Throwable $e) { + Console::error("Request handler error: {$e->getMessage()}"); + $response->status(500); + $response->end('Internal Server Error'); + } + + return; + } + + try { + if ($this->config->directResponse !== null) { + $response->status($this->config->directResponseStatus); + $response->end($this->config->directResponse); + + return; + } + + $endpoint = is_string($this->config->fixedBackend) ? $this->config->fixedBackend : null; + $result = null; + if ($endpoint === null) { + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + $hostname = $requestHeaders['host'] ?? null; + + if (!$hostname) { + $response->status(400); + $response->end('Missing Host header'); + + return; + } + + if (!$this->isValidHostname($hostname)) { + $response->status(400); + $response->end('Invalid Host header'); + + return; + } + + $result = $this->adapter->route($hostname); + $endpoint = $result->endpoint; + } + + $telemetry = null; + if ($this->config->telemetry && !$this->config->fastPath) { + $telemetry = new Telemetry( + startTime: microtime(true), + result: $result, + ); + } + + /** @var string $endpoint */ + if ($this->config->rawBackend) { + $this->forwardRawRequest($request, $response, $endpoint, $telemetry); + } else { + $this->forwardRequest($request, $response, $endpoint, $telemetry); + } + + } catch (\Exception $e) { + Console::error("Proxy error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}"); + + $response->status(503); + $response->header('Content-Type', 'application/json'); + $response->end(json_encode([ + 'error' => 'Service Unavailable', + 'message' => 'The requested service is temporarily unavailable', + ])); + } + } + + protected function forwardRequest(Request $request, Response $response, string $endpoint, ?Telemetry $telemetry = null): void + { + [$host, $port] = Adapter::parseEndpoint($endpoint, 80); + + $poolKey = "{$host}:{$port}"; + if (!isset($this->pools[$poolKey])) { + $this->pools[$poolKey] = new Channel($this->config->poolSize); + } + $pool = $this->pools[$poolKey]; + + $isNew = false; + $client = $pool->pop($this->config->poolTimeout); + if (!$client instanceof HttpClient) { + $client = new HttpClient($host, $port); + $client->set([ + 'timeout' => $this->config->timeout, + 'keep_alive' => $this->config->keepAlive, + ]); + $isNew = true; + } + + if ($this->config->fastPath) { + if ($isNew) { + $client->setHeaders([ + 'Host' => $port === 80 ? $host : "{$host}:{$port}", + ]); + } + } else { + $headers = []; + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + foreach ($requestHeaders as $key => $value) { + $lower = strtolower($key); + if ($lower !== 'host' && $lower !== 'connection') { + $headers[$key] = $value; + } + } + $headers['Host'] = $port === 80 ? $host : "{$host}:{$port}"; + $client->setHeaders($headers); + if (!empty($request->cookie)) { + /** @var array $cookies */ + $cookies = $request->cookie; + $client->setCookies($cookies); + } + } + + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + $path = $requestServer['request_uri'] ?? '/'; + $query = $requestServer['query_string'] ?? ''; + if ($query !== '') { + $path .= '?'.$query; + } + $body = ''; + if ($method !== 'GET' && $method !== 'HEAD') { + $body = $request->getContent() ?: ''; + } + + switch ($method) { + case 'GET': + $client->get($path); + break; + case 'POST': + $client->post($path, $body); + break; + case 'HEAD': + $client->setMethod($method); + $client->execute($path); + break; + default: + $client->setMethod($method); + if ($body !== '') { + $client->setData($body); + } + $client->execute($path); + break; + } + + if (!$this->config->fastPathAssumeOk) { + $response->status($client->statusCode); + } + + if (!$this->config->fastPath) { + if (!empty($client->headers)) { + /** @var array $responseHeaders */ + $responseHeaders = $client->headers; + foreach ($responseHeaders as $key => $value) { + $response->header($key, $value); + } + } + + if (!empty($client->set_cookie_headers)) { + /** @var list $cookieHeaders */ + $cookieHeaders = $client->set_cookie_headers; + foreach ($cookieHeaders as $cookie) { + $response->header('Set-Cookie', $cookie); + } + } + } + + $this->addTelemetryHeaders($response, $telemetry); + + $response->end($client->body); + + if ($client->connected) { + if (!$pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + /** + * Raw TCP HTTP forwarder for benchmark-only usage. + * + * Assumptions: + * - Backend replies with Content-Length (no chunked encoding). + * - Only GET/HEAD are supported; other methods fall back to HTTP client. + */ + protected function forwardRawRequest(Request $request, Response $response, string $endpoint, ?Telemetry $telemetry = null): void + { + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + if ($method !== 'GET' && $method !== 'HEAD') { + $this->forwardRequest($request, $response, $endpoint, $telemetry); + + return; + } + + [$host, $port] = Adapter::parseEndpoint($endpoint, 80); + + $poolKey = "raw:{$host}:{$port}"; + if (!isset($this->pools[$poolKey])) { + $this->pools[$poolKey] = new Channel($this->config->poolSize); + } + $pool = $this->pools[$poolKey]; + + $client = $pool->pop($this->config->poolTimeout); + if (!$client instanceof CoroutineClient || !$client->isConnected()) { + $client = new CoroutineClient(SWOOLE_SOCK_TCP); + $client->set([ + 'timeout' => $this->config->timeout, + ]); + if (!$client->connect($host, $port, $this->config->connectTimeout)) { + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + } + + $path = $requestServer['request_uri'] ?? '/'; + $query = $requestServer['query_string'] ?? ''; + if ($query !== '') { + $path .= '?'.$query; + } + $hostHeader = $port === 80 ? $host : "{$host}:{$port}"; + $requestLine = $method.' '.$path." HTTP/1.1\r\n". + 'Host: '.$hostHeader."\r\n". + "Connection: keep-alive\r\n\r\n"; + + if ($client->send($requestLine) === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + + $buffer = ''; + while (strpos($buffer, "\r\n\r\n") === false) { + /** @var string|false $chunk */ + $chunk = $client->recv(8192); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + $buffer .= $chunk; + } + + [$headerPart, $bodyPart] = explode("\r\n\r\n", $buffer, 2); + $contentLength = null; + $statusCode = 200; + $chunked = false; + + $lines = explode("\r\n", $headerPart); + if (preg_match('/^HTTP\/\\d+\\.\\d+\\s+(\\d+)/', $lines[0], $matches)) { + $statusCode = (int) $matches[1]; + } + $skipHeaders = ['connection', 'keep-alive', 'transfer-encoding', 'content-length']; + for ($i = 1; $i < count($lines); $i++) { + $colonPos = strpos($lines[$i], ':'); + if ($colonPos === false) { + continue; + } + $key = substr($lines[$i], 0, $colonPos); + $value = trim(substr($lines[$i], $colonPos + 1)); + $lower = strtolower($key); + if ($lower === 'content-length') { + $contentLength = (int) $value; + } elseif ($lower === 'transfer-encoding' && stripos($value, 'chunked') !== false) { + $chunked = true; + } + if (!in_array($lower, $skipHeaders, true)) { + $response->header($key, $value); + } + } + + if (!$this->config->rawBackendAssumeOk) { + $response->status($statusCode); + } + + if ($chunked || $contentLength === null) { + $response->end($bodyPart); + $client->close(); + + return; + } + + /** @var string $bodyPartStr */ + $bodyPartStr = $bodyPart; + $body = $bodyPartStr; + $remaining = $contentLength - strlen($bodyPartStr); + while ($remaining > 0) { + $chunk = $client->recv(min(8192, $remaining)); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + /** @var string $chunkStr */ + $chunkStr = $chunk; + $body .= $chunkStr; + $remaining -= strlen($chunkStr); + } + + $this->addTelemetryHeaders($response, $telemetry); + + $response->end($body); + + if ($client->isConnected()) { + if (!$pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + protected function addTelemetryHeaders(Response $response, ?Telemetry $telemetry): void + { + if ($telemetry === null) { + return; + } + + $latency = round((microtime(true) - $telemetry->startTime) * 1000, 2); + $response->header('X-Proxy-Latency-Ms', (string) $latency); + + if ($telemetry->result !== null) { + $response->header('X-Proxy-Protocol', $telemetry->result->protocol->value); + + if (isset($telemetry->result->metadata['cached'])) { + $response->header('X-Proxy-Cache', $telemetry->result->metadata['cached'] ? 'HIT' : 'MISS'); + } + } + } + + protected function isValidHostname(string $hostname): bool + { + $host = preg_replace('/:\d+$/', '', $hostname); + if ($host === null) { + return false; + } + + return (new Hostname())->isValid($host); + } +} diff --git a/src/Server/HTTP/Telemetry.php b/src/Server/HTTP/Telemetry.php new file mode 100644 index 0000000..d89bfdd --- /dev/null +++ b/src/Server/HTTP/Telemetry.php @@ -0,0 +1,14 @@ +state === 'data'; + } +} diff --git a/src/Server/SMTP/Swoole.php b/src/Server/SMTP/Swoole.php new file mode 100644 index 0000000..90a93ae --- /dev/null +++ b/src/Server/SMTP/Swoole.php @@ -0,0 +1,312 @@ +start(); + * ``` + */ +class Swoole +{ + private const RECV_BUFFER = 8192; + + private const PACKAGE_MAX_LENGTH = 10 * 1024 * 1024; + + private const GREETING = "220 utopia-php.io ESMTP Proxy\r\n"; + + private const GREETING_CODE = '220'; + + private const DATA_READY_CODE = '354'; + + private const ERROR_UNKNOWN_COMMAND = "500 Unknown command\r\n"; + + private const ERROR_SYNTAX = "501 Syntax error\r\n"; + + private const ERROR_UNAVAILABLE = "421 Service not available\r\n"; + + private const DATA_TERMINATOR = '.'; + + private const DEFAULT_PORT = 25; + + protected Server $server; + + protected Adapter $adapter; + + protected Config $config; + + /** @var array */ + protected array $connections = []; + + public function __construct( + protected Resolver $resolver, + ?Config $config = null, + ) { + $this->config = $config ?? new Config(); + $this->server = new Server( + $this->config->host, + $this->config->port, + SWOOLE_PROCESS, + SWOOLE_SOCK_TCP, + ); + $this->configure(); + } + + protected function configure(): void + { + $this->server->set([ + 'worker_num' => $this->config->workers, + 'max_connection' => $this->config->maxConnections, + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'buffer_output_size' => $this->config->bufferOutputSize, + 'enable_coroutine' => $this->config->enableCoroutine, + 'max_wait_time' => $this->config->maxWaitTime, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'open_length_check' => false, + 'open_eof_check' => true, + 'package_eof' => "\r\n", + 'package_max_length' => self::PACKAGE_MAX_LENGTH, + 'task_enable_coroutine' => true, + ]); + + $this->server->on(Constant::EVENT_START, $this->onStart(...)); + $this->server->on(Constant::EVENT_WORKER_START, $this->onWorkerStart(...)); + $this->server->on(Constant::EVENT_CONNECT, $this->onConnect(...)); + $this->server->on(Constant::EVENT_RECEIVE, $this->onReceive(...)); + $this->server->on(Constant::EVENT_CLOSE, $this->onClose(...)); + } + + public function onStart(Server $server): void + { + Console::success("SMTP Proxy Server started at {$this->config->host}:{$this->config->port}"); + Console::log("Workers: {$this->config->workers}"); + Console::log("Max connections: {$this->config->maxConnections}"); + } + + public function onWorkerStart(Server $server, int $workerId): void + { + $this->adapter = new Adapter( + $this->resolver, + protocol: Protocol::SMTP + ); + $this->adapter->setCacheTTL($this->config->cacheTTL); + + if ($this->config->skipValidation) { + $this->adapter->setSkipValidation(true); + } + + Console::log("Worker #{$workerId} started ({$this->adapter->getProtocol()->value})"); + } + + /** + * Handle new SMTP connection - send greeting + */ + public function onConnect(Server $server, int $fd, int $reactorId): void + { + Console::log("Client #{$fd} connected"); + + $server->send($fd, self::GREETING); + + $this->connections[$fd] = new Connection(); + } + + /** + * Main SMTP command handler + */ + public function onReceive(Server $server, int $fd, int $reactorId, string $data): void + { + try { + if (!isset($this->connections[$fd])) { + $this->connections[$fd] = new Connection(); + } + + $connection = $this->connections[$fd]; + + if ($connection->isData()) { + $this->forwardData($server, $fd, $data, $connection); + + return; + } + + $command = strtoupper(substr(trim($data), 0, 4)); + + switch ($command) { + case 'EHLO': + case 'HELO': + $this->handleHelo($server, $fd, $data, $connection); + break; + + case 'MAIL': + case 'RCPT': + case 'DATA': + case 'RSET': + case 'NOOP': + case 'QUIT': + $this->forwardCommand($server, $fd, $data, $connection); + break; + + default: + $server->send($fd, self::ERROR_UNKNOWN_COMMAND); + } + + } catch (\Exception $e) { + Console::error("Error handling SMTP from #{$fd}: {$e->getMessage()}"); + $server->send($fd, self::ERROR_UNAVAILABLE); + $server->close($fd); + } + } + + /** + * Handle EHLO/HELO - extract domain and route to backend + */ + protected function handleHelo(Server $server, int $fd, string $data, Connection $connection): void + { + if (preg_match('/^(EHLO|HELO)\s+([^\s]+)/i', $data, $matches)) { + $domain = $matches[2]; + $connection->domain = $domain; + + $result = $this->adapter->route($domain); + + $connection->backend = $this->connectToBackend($result->endpoint, self::DEFAULT_PORT); + + $this->forwardCommand($server, $fd, $data, $connection); + + } else { + $server->send($fd, self::ERROR_SYNTAX); + } + } + + /** + * Forward SMTP command to backend and relay response inline. + * + * SMTP is a sequential request-response protocol so we recv + * inline rather than spawning a goroutine. + */ + protected function forwardCommand(Server $server, int $fd, string $data, Connection $connection): void + { + if ($connection->backend === null) { + throw new \Exception('No backend connection'); + } + + $isDataCommand = strtoupper(substr(trim($data), 0, 4)) === 'DATA' + && strtoupper(trim($data)) === 'DATA'; + + if ($connection->backend->send($data) === false) { + throw new \Exception('Failed to send command to backend'); + } + + /** @var string|false $response */ + $response = $connection->backend->recv(self::RECV_BUFFER); + + if (\is_string($response) && $response !== '') { + $server->send($fd, $response); + + if ($isDataCommand && str_starts_with($response, self::DATA_READY_CODE)) { + $connection->state = 'data'; + } + } + } + + /** + * Forward raw message body data during DATA mode. + * + * In DATA mode all lines are forwarded verbatim without command + * parsing. The mode ends when the terminator line (single dot) is seen. + */ + protected function forwardData(Server $server, int $fd, string $data, Connection $connection): void + { + if ($connection->backend === null) { + throw new \Exception('No backend connection'); + } + + if ($connection->backend->send($data) === false) { + throw new \Exception('Failed to send data to backend'); + } + + if (trim($data) === self::DATA_TERMINATOR) { + $connection->state = 'command'; + + $response = $connection->backend->recv(self::RECV_BUFFER); + + if ($response !== false && $response !== '') { + $server->send($fd, $response); + } + } + } + + protected function connectToBackend(string $endpoint, int $defaultPort): Client + { + [$host, $port] = Adapter::parseEndpoint($endpoint, $defaultPort); + + $client = new Client(SWOOLE_SOCK_TCP); + + $client->set([ + 'timeout' => $this->config->timeout, + ]); + + if (!$client->connect($host, $port, $this->config->connectTimeout)) { + throw new \Exception("Failed to connect to backend SMTP: {$host}:{$port}"); + } + + /** @var string|false $greeting */ + $greeting = $client->recv(self::RECV_BUFFER); + + if (!\is_string($greeting) || $greeting === '' || !str_starts_with(trim($greeting), self::GREETING_CODE)) { + $client->close(); + throw new \Exception('Backend SMTP greeting failed: '.(\is_string($greeting) ? $greeting : 'no response')); + } + + return $client; + } + + public function onClose(Server $server, int $fd, int $reactorId): void + { + Console::log("Client #{$fd} disconnected"); + + if (isset($this->connections[$fd]) && $this->connections[$fd]->backend !== null) { + $this->connections[$fd]->backend->close(); + } + + unset($this->connections[$fd]); + } + + public function start(): void + { + $this->server->start(); + } + + /** + * @return array + */ + public function getStats(): array + { + /** @var array $serverStats */ + $serverStats = $this->server->stats(); + /** @var array $coroutineStats */ + $coroutineStats = Coroutine::stats(); + + return [ + 'connections' => $serverStats['connection_num'] ?? 0, + 'workers' => $serverStats['worker_num'] ?? 0, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapter' => $this->adapter->getStats(), + ]; + } +} diff --git a/src/Server/TCP/Config.php b/src/Server/TCP/Config.php new file mode 100644 index 0000000..c624f78 --- /dev/null +++ b/src/Server/TCP/Config.php @@ -0,0 +1,62 @@ + $ports + */ + public function __construct( + public readonly array $ports, + public readonly string $host = '0.0.0.0', + public readonly int $workers = 16, + public readonly int $maxConnections = 200_000, + public readonly int $maxCoroutine = 200_000, + public readonly int $socketBufferSize = 16 * 1024 * 1024, + public readonly int $bufferOutputSize = 16 * 1024 * 1024, + ?int $reactorNum = null, + public readonly int $dispatchMode = 2, + public readonly bool $enableReusePort = true, + public readonly int $backlog = 65535, + public readonly int $packageMaxLength = 32 * 1024 * 1024, + public readonly int $tcpKeepidle = 30, + public readonly int $tcpKeepinterval = 10, + public readonly int $tcpKeepcount = 3, + public readonly bool $enableCoroutine = true, + public readonly int $maxWaitTime = 60, + public readonly int $logLevel = SWOOLE_LOG_ERROR, + public readonly bool $logConnections = false, + public readonly int $receiveBufferSize = 131072, + public readonly float $timeout = 30.0, + public readonly float $connectTimeout = 5.0, + public readonly bool $skipValidation = false, + public readonly int $cacheTTL = 0, + public readonly ?TLS $tls = null, + public readonly ?\Closure $adapterFactory = null, + ) { + $this->reactorNum = $reactorNum ?? swoole_cpu_num() * 2; + } + + /** + * Check if TLS termination is enabled + */ + public function isTlsEnabled(): bool + { + return $this->tls !== null; + } + + /** + * Get the TLS context builder, or null if TLS is not configured + */ + public function getTLSContext(): ?TLSContext + { + if ($this->tls === null) { + return null; + } + + return new TLSContext($this->tls); + } +} diff --git a/src/Server/TCP/Swoole.php b/src/Server/TCP/Swoole.php new file mode 100644 index 0000000..a378f2e --- /dev/null +++ b/src/Server/TCP/Swoole.php @@ -0,0 +1,368 @@ +start(); + * ``` + */ +class Swoole +{ + protected Server $server; + + /** @var array */ + protected array $adapters = []; + + protected Config $config; + + protected ?TLSContext $tlsContext = null; + + /** @var array Primary/default backend connections */ + protected array $clients = []; + + /** @var array */ + protected array $clientPorts = []; + + /** + * Tracks connections awaiting TLS upgrade (PostgreSQL STARTTLS). + * After sending 'S' in response to SSLRequest, the connection + * must complete the TLS handshake before we see the real startup message. + * + * @var array + */ + protected array $pendingTls = []; + + protected ?Resolver $resolver; + + public function __construct( + Config $config, + ?Resolver $resolver = null, + ) { + $this->resolver = $resolver; + $this->config = $config; + + if ($this->config->isTlsEnabled()) { + /** @var TLS $tls */ + $tls = $this->config->tls; + $tls->validate(); + $this->tlsContext = $this->config->getTLSContext(); + } + + $socketType = $this->tlsContext !== null + ? $this->tlsContext->getSocketType() + : SWOOLE_SOCK_TCP; + + // Create main server on first port + $this->server = new Server( + $this->config->host, + $this->config->ports[0], + SWOOLE_PROCESS, + $socketType, + ); + + // Add listeners for additional ports + for ($i = 1; $i < count($this->config->ports); $i++) { + $this->server->addlistener( + $this->config->host, + $this->config->ports[$i], + $socketType, + ); + } + + $this->configure(); + } + + protected function configure(): void + { + $settings = [ + 'worker_num' => $this->config->workers, + 'reactor_num' => $this->config->reactorNum, + 'max_connection' => $this->config->maxConnections, + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'buffer_output_size' => $this->config->bufferOutputSize, + 'enable_coroutine' => $this->config->enableCoroutine, + 'max_wait_time' => $this->config->maxWaitTime, + 'log_level' => $this->config->logLevel, + 'dispatch_mode' => $this->config->dispatchMode, + 'enable_reuse_port' => $this->config->enableReusePort, + 'backlog' => $this->config->backlog, + + // TCP performance tuning + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + 'open_tcp_keepalive' => true, + 'tcp_keepidle' => $this->config->tcpKeepidle, + 'tcp_keepinterval' => $this->config->tcpKeepinterval, + 'tcp_keepcount' => $this->config->tcpKeepcount, + + 'open_length_check' => false, + 'package_max_length' => $this->config->packageMaxLength, + + // Enable stats + 'task_enable_coroutine' => true, + ]; + + // Apply TLS settings when enabled + if ($this->tlsContext !== null) { + $settings = array_merge($settings, $this->tlsContext->toSwooleConfig()); + } + + $this->server->set($settings); + + $this->server->on(Constant::EVENT_START, $this->onStart(...)); + $this->server->on(Constant::EVENT_WORKER_START, $this->onWorkerStart(...)); + $this->server->on(Constant::EVENT_CONNECT, $this->onConnect(...)); + $this->server->on(Constant::EVENT_RECEIVE, $this->onReceive(...)); + $this->server->on(Constant::EVENT_CLOSE, $this->onClose(...)); + } + + public function onStart(Server $server): void + { + Console::success("TCP Proxy Server started at {$this->config->host}"); + Console::log('Ports: '.implode(', ', $this->config->ports)); + Console::log("Workers: {$this->config->workers}"); + Console::log("Max connections: {$this->config->maxConnections}"); + + if ($this->config->isTlsEnabled()) { + Console::info('TLS: enabled'); + if ($this->config->tls?->isMutual()) { + Console::info('mTLS: enabled (client certificates required)'); + } + } + } + + public function onWorkerStart(Server $server, int $workerId): void + { + // Initialize TCP adapter per worker per port + foreach ($this->config->ports as $port) { + if ($this->config->adapterFactory !== null) { + /** @var TCPAdapter $adapter */ + $adapter = ($this->config->adapterFactory)($port); + } else { + $adapter = new TCPAdapter(port: $port, resolver: $this->resolver); + } + + if ($this->config->skipValidation) { + $adapter->setSkipValidation(true); + } + + if ($this->config->cacheTTL > 0) { + $adapter->setCacheTTL($this->config->cacheTTL); + } + + $adapter->setTimeout($this->config->timeout); + $adapter->setConnectTimeout($this->config->connectTimeout); + + $this->adapters[$port] = $adapter; + } + + Console::log("Worker #{$workerId} started"); + } + + /** + * Handle new TCP connection + */ + public function onConnect(Server $server, int $fd, int $reactorId): void + { + /** @var array $info */ + $info = $server->getClientInfo($fd); + /** @var int $port */ + $port = $info['server_port'] ?? 0; + $this->clientPorts[$fd] = $port; + + if ($this->config->logConnections) { + Console::log("Client #{$fd} connected to port {$port}"); + } + } + + /** + * Main receive handler + * + * When TLS is enabled, handles protocol-specific SSL negotiation: + * - PostgreSQL: Intercepts SSLRequest, responds 'S', Swoole upgrades to TLS + * - MySQL: Swoole handles SSL natively via SWOOLE_SSL socket type + */ + public function onReceive(Server $server, int $fd, int $reactorId, string $data): void + { + $resourceId = (string) $fd; + + // Fast path: existing connection - forward to appropriate backend + if (isset($this->clients[$fd])) { + $port = $this->clientPorts[$fd] ?? 0; + $adapter = $this->adapters[$port] ?? null; + + if ($adapter !== null) { + $adapter->recordBytes($resourceId, \strlen($data), 0); + $adapter->track($resourceId); + } + + if ($this->clients[$fd]->send($data) === false) { + $server->close($fd); + } + + return; + } + + // Handle PostgreSQL STARTTLS: SSLRequest comes before the real startup message. + if ($this->tlsContext !== null && TLS::isPostgreSQLSSLRequest($data)) { + $port = $this->clientPorts[$fd] ?? null; + if ($port !== null && $port === 5432) { + $server->send($fd, TLS::PG_SSL_RESPONSE_OK); + $this->pendingTls[$fd] = true; + + return; + } + } + + if (isset($this->pendingTls[$fd])) { + unset($this->pendingTls[$fd]); + } + + // Slow path: new connection setup + try { + $port = $this->clientPorts[$fd] ?? null; + if ($port === null) { + /** @var array $info */ + $info = $server->getClientInfo($fd); + /** @var int $port */ + $port = $info['server_port'] ?? 0; + if ($port === 0) { + throw new \Exception('Missing server port for connection'); + } + $this->clientPorts[$fd] = $port; + } + + $adapter = $this->adapters[$port] ?? null; + if ($adapter === null) { + throw new \Exception("No adapter registered for port {$port}"); + } + + // Route via resolver β€” the resolver receives raw initial data + // and is responsible for extracting any routing information + $backendClient = $adapter->getConnection($data, $fd); + $this->clients[$fd] = $backendClient; + + $adapter->notifyConnect($resourceId); + + // Forward initial data to primary + $backendClient->send($data); + + $this->forward($server, $fd, $backendClient); + + } catch (\Exception $e) { + Console::error("Error handling data from #{$fd}: {$e->getMessage()}"); + $server->close($fd); + } + } + + /** + * Bidirectional forwarding loop + */ + protected function forward(Server $server, int $clientFd, Client $backendClient): void + { + $bufferSize = $this->config->receiveBufferSize; + /** @var Socket $backendSocket */ + $backendSocket = $backendClient->exportSocket(); + + $resourceId = (string) $clientFd; + $port = $this->clientPorts[$clientFd] ?? null; + $adapter = ($port !== null) ? ($this->adapters[$port] ?? null) : null; + + \go(function () use ($server, $clientFd, $backendSocket, $bufferSize, $resourceId, $adapter) { + while ($server->exist($clientFd)) { + /** @var string|false $data */ + $data = $backendSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + if ($adapter !== null) { + $adapter->recordBytes($resourceId, 0, \strlen($data)); + } + if ($server->send($clientFd, $data) === false) { + break; + } + } + $server->close($clientFd); + }); + } + + public function onClose(Server $server, int $fd, int $reactorId): void + { + if ($this->config->logConnections) { + Console::log("Client #{$fd} disconnected"); + } + + if (isset($this->clients[$fd])) { + $this->clients[$fd]->close(); + unset($this->clients[$fd]); + } + + if (isset($this->clientPorts[$fd])) { + $port = $this->clientPorts[$fd]; + $adapter = $this->adapters[$port] ?? null; + if ($adapter) { + $adapter->notifyClose((string) $fd); + $adapter->closeConnection($fd); + } + } + + unset($this->clientPorts[$fd]); + unset($this->pendingTls[$fd]); + } + + public function start(): void + { + $this->server->start(); + } + + /** + * @return array + */ + public function getStats(): array + { + $adapterStats = []; + foreach ($this->adapters as $port => $adapter) { + $adapterStats[$port] = $adapter->getStats(); + } + + /** @var array $serverStats */ + $serverStats = $this->server->stats(); + /** @var array $coroutineStats */ + $coroutineStats = Coroutine::stats(); + + return [ + 'connections' => $serverStats['connection_num'] ?? 0, + 'workers' => $serverStats['worker_num'] ?? 0, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapters' => $adapterStats, + ]; + } +} diff --git a/src/Server/TCP/Swoole/Coroutine.php b/src/Server/TCP/Swoole/Coroutine.php new file mode 100644 index 0000000..71ae98d --- /dev/null +++ b/src/Server/TCP/Swoole/Coroutine.php @@ -0,0 +1,296 @@ +start(); + * ``` + */ +class Coroutine +{ + /** @var array */ + protected array $servers = []; + + /** @var array */ + protected array $adapters = []; + + protected Config $config; + + protected ?TLSContext $tlsContext = null; + + public function __construct( + Config $config, + protected Resolver $resolver, + ) { + $this->config = $config; + + if ($this->config->isTlsEnabled()) { + /** @var TLS $tls */ + $tls = $this->config->tls; + $tls->validate(); + $this->tlsContext = $this->config->getTLSContext(); + } + + $this->initAdapters(); + $this->configureServers(); + } + + protected function initAdapters(): void + { + foreach ($this->config->ports as $port) { + if ($this->config->adapterFactory !== null) { + /** @var TCPAdapter $adapter */ + $adapter = ($this->config->adapterFactory)($port); + } else { + $adapter = new TCPAdapter(port: $port, resolver: $this->resolver); + } + + if ($this->config->skipValidation) { + $adapter->setSkipValidation(true); + } + + if ($this->config->cacheTTL > 0) { + $adapter->setCacheTTL($this->config->cacheTTL); + } + + $adapter->setTimeout($this->config->timeout); + $adapter->setConnectTimeout($this->config->connectTimeout); + + $this->adapters[$port] = $adapter; + } + } + + protected function configureServers(): void + { + SwooleCoroutine::set([ + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'log_level' => $this->config->logLevel, + ]); + + $ssl = $this->tlsContext !== null; + + foreach ($this->config->ports as $port) { + $server = new CoroutineServer($this->config->host, $port, $ssl, $this->config->enableReusePort); + + $settings = [ + 'open_tcp_nodelay' => true, + 'open_tcp_keepalive' => true, + 'tcp_keepidle' => $this->config->tcpKeepidle, + 'tcp_keepinterval' => $this->config->tcpKeepinterval, + 'tcp_keepcount' => $this->config->tcpKeepcount, + 'open_length_check' => false, + 'package_max_length' => $this->config->packageMaxLength, + 'buffer_output_size' => $this->config->bufferOutputSize, + ]; + + if ($this->tlsContext !== null) { + $settings = array_merge($settings, $this->tlsContext->toSwooleConfig()); + } + + $server->set($settings); + + $server->handle(function (Connection $connection) use ($port): void { + $this->handleConnection($connection, $port); + }); + + $this->servers[$port] = $server; + } + } + + public function onStart(): void + { + Console::success("TCP Proxy Server started at {$this->config->host}"); + Console::log('Ports: '.implode(', ', $this->config->ports)); + Console::log("Workers: {$this->config->workers}"); + Console::log("Max connections: {$this->config->maxConnections}"); + + if ($this->config->isTlsEnabled()) { + Console::info('TLS: enabled'); + if ($this->config->tls?->isMutual()) { + Console::info('mTLS: enabled (client certificates required)'); + } + } + } + + public function onWorkerStart(int $workerId = 0): void + { + Console::log("Worker #{$workerId} started"); + } + + protected function handleConnection(Connection $connection, int $port): void + { + /** @var Socket $clientSocket */ + $clientSocket = $connection->exportSocket(); + $clientId = spl_object_id($connection); + $adapter = $this->adapters[$port]; + $bufferSize = $this->config->receiveBufferSize; + + if ($this->config->logConnections) { + Console::log("Client #{$clientId} connected to port {$port}"); + } + + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + $clientSocket->close(); + + return; + } + + // Handle PostgreSQL STARTTLS negotiation. + // PG clients send an SSLRequest before the real startup message. + // When TLS is enabled with Swoole's coroutine SSL server, the TLS + // handshake is handled at the transport level. We respond with 'S' + // to satisfy the PG protocol, then read the real startup message. + if ($this->tlsContext !== null && $port === 5432 && TLS::isPostgreSQLSSLRequest($data)) { + $clientSocket->sendAll(TLS::PG_SSL_RESPONSE_OK); + + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + $clientSocket->close(); + + return; + } + } + + $resourceId = (string) $clientId; + $done = new Channel(1); + + try { + $backendClient = $adapter->getConnection($data, $clientId); + } catch (\Exception $e) { + Console::error("Error handling data from #{$clientId}: {$e->getMessage()}"); + $clientSocket->close(); + + return; + } + + /** @var Socket $backendSocket */ + $backendSocket = $backendClient->exportSocket(); + + $adapter->notifyConnect($resourceId); + + \go(function () use ($clientSocket, $backendSocket, $bufferSize, $adapter, $resourceId, $done): void { + while (true) { + /** @var string|false $data */ + $data = $backendSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + $adapter->recordBytes($resourceId, 0, \strlen($data)); + if ($clientSocket->sendAll($data) === false) { + break; + } + } + $done->push(true); + }); + + $adapter->recordBytes($resourceId, \strlen($data), 0); + if ($backendSocket->sendAll($data) === false) { + $backendSocket->close(); + $done->pop(1.0); + $clientSocket->close(); + $adapter->notifyClose($resourceId); + $adapter->closeConnection($clientId); + + return; + } + + while (true) { + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + $adapter->recordBytes($resourceId, \strlen($data), 0); + $adapter->track($resourceId); + if ($backendSocket->sendAll($data) === false) { + break; + } + } + + $backendSocket->close(); + $done->pop(); + $clientSocket->close(); + + $adapter->notifyClose($resourceId); + $adapter->closeConnection($clientId); + + if ($this->config->logConnections) { + Console::log("Client #{$clientId} disconnected"); + } + } + + public function start(): void + { + $runner = function (): void { + $this->onStart(); + $this->onWorkerStart(0); + + foreach ($this->servers as $server) { + \go(function () use ($server): void { + $server->start(); + }); + } + }; + + if (SwooleCoroutine::getCid() > 0) { + $runner(); + + return; + } + + SwooleCoroutine\run($runner); + } + + /** + * @return array + */ + public function getStats(): array + { + $adapterStats = []; + foreach ($this->adapters as $port => $adapter) { + $adapterStats[$port] = $adapter->getStats(); + } + + /** @var array $coroutineStats */ + $coroutineStats = SwooleCoroutine::stats(); + + return [ + 'connections' => 0, + 'workers' => 1, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapters' => $adapterStats, + ]; + } +} diff --git a/src/Server/TCP/TLS.php b/src/Server/TCP/TLS.php new file mode 100644 index 0000000..92a4740 --- /dev/null +++ b/src/Server/TCP/TLS.php @@ -0,0 +1,158 @@ +isValid($this->certificate)) { + throw new \RuntimeException("TLS certificate path is invalid: {$path->getDescription()}"); + } + + if (!is_readable($this->certificate)) { + throw new \RuntimeException("TLS certificate file not readable: {$this->certificate}"); + } + + if (!$path->isValid($this->key)) { + throw new \RuntimeException("TLS key path is invalid: {$path->getDescription()}"); + } + + if (!is_readable($this->key)) { + throw new \RuntimeException("TLS private key file not readable: {$this->key}"); + } + + if ($this->requireClientCert && $this->ca === '') { + throw new \RuntimeException('CA certificate path is required when client certificate verification is enabled'); + } + + if ($this->ca !== '' && !$path->isValid($this->ca)) { + throw new \RuntimeException("TLS CA path is invalid: {$path->getDescription()}"); + } + + if ($this->ca !== '' && !is_readable($this->ca)) { + throw new \RuntimeException("TLS CA certificate file not readable: {$this->ca}"); + } + } + + /** + * Check if this is an mTLS configuration (requires client certificates) + */ + public function isMutual(): bool + { + return $this->requireClientCert && $this->ca !== ''; + } + + /** + * Detect whether a raw data packet is a PostgreSQL SSLRequest message + * + * The SSLRequest is exactly 8 bytes: + * - Int32(8): length + * - Int32(80877103): SSL request code (0x04D2162F) + */ + public static function isPostgreSQLSSLRequest(string $data): bool + { + return strlen($data) === 8 && $data === self::PG_SSL_REQUEST; + } + + /** + * Detect whether a raw data packet is a MySQL SSL handshake request + * + * After receiving the server greeting with SSL capability flag, + * the client sends an SSL request packet. This is identified by: + * - Packet length >= 4 bytes (header) + * - Capability flags in bytes 4-7 include CLIENT_SSL (0x0800) + * - Sequence ID = 1 (byte 3) + */ + public static function isMySQLSSLRequest(string $data): bool + { + if (strlen($data) < 36) { + return false; + } + + // Sequence ID should be 1 (client response to server greeting) + if (ord($data[3]) !== 1) { + return false; + } + + // Read capability flags (little-endian uint16 at offset 4) + $capLow = ord($data[4]) | (ord($data[5]) << 8); + + return ($capLow & self::MYSQL_CLIENT_SSL_FLAG) !== 0; + } +} diff --git a/src/Server/TCP/TLSContext.php b/src/Server/TCP/TLSContext.php new file mode 100644 index 0000000..cc354cd --- /dev/null +++ b/src/Server/TCP/TLSContext.php @@ -0,0 +1,139 @@ +set($ctx->toSwooleConfig()); + * + * // For stream_context_create + * $streamCtx = $ctx->toStreamContext(); + * ``` + */ +class TLSContext +{ + public function __construct( + protected TLS $tls, + ) { + } + + /** + * Build Swoole server SSL configuration array + * + * Returns settings suitable for Swoole\Server::set() when the server + * is created with SWOOLE_SOCK_TCP | SWOOLE_SSL socket type. + * + * @return array + */ + public function toSwooleConfig(): array + { + $config = [ + 'ssl_cert_file' => $this->tls->certificate, + 'ssl_key_file' => $this->tls->key, + 'ssl_protocols' => $this->protocolMask($this->tls->minProtocol), + 'ssl_ciphers' => $this->tls->ciphers, + 'ssl_allow_self_signed' => false, + ]; + + if ($this->tls->ca !== '') { + $config['ssl_client_cert_file'] = $this->tls->ca; + } + + if ($this->tls->requireClientCert) { + $config['ssl_verify_peer'] = true; + $config['ssl_verify_depth'] = 10; + } else { + $config['ssl_verify_peer'] = false; + } + + return $config; + } + + /** + * Build a PHP stream context resource for SSL connections + * + * Returns a context resource that can be used with stream_socket_server, + * stream_socket_enable_crypto, and similar stream functions. + * + * @return resource + */ + public function toStreamContext(): mixed + { + $sslOptions = [ + 'local_cert' => $this->tls->certificate, + 'local_pk' => $this->tls->key, + 'disable_compression' => true, + 'allow_self_signed' => false, + 'ciphers' => $this->tls->ciphers, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + ]; + + if ($this->tls->ca !== '') { + $sslOptions['cafile'] = $this->tls->ca; + } + + if ($this->tls->requireClientCert) { + $sslOptions['verify_peer'] = true; + $sslOptions['verify_peer_name'] = false; + $sslOptions['verify_depth'] = 10; + } else { + $sslOptions['verify_peer'] = false; + $sslOptions['verify_peer_name'] = false; + } + + return stream_context_create(['ssl' => $sslOptions]); + } + + private function protocolMask(int $minimum): int + { + $protocols = [ + SWOOLE_SSL_TLSv1 => 1, + SWOOLE_SSL_TLSv1_1 => 2, + SWOOLE_SSL_TLSv1_2 => 3, + SWOOLE_SSL_TLSv1_3 => 4, + ]; + + $minOrder = $protocols[$minimum] ?? 3; + $mask = 0; + + foreach ($protocols as $constant => $order) { + if ($order >= $minOrder) { + $mask |= $constant; + } + } + + return $mask; + } + + /** + * Get the Swoole socket type flag for TLS-enabled TCP + * + * Combines SWOOLE_SOCK_TCP with SWOOLE_SSL when TLS is configured. + */ + public function getSocketType(): int + { + return SWOOLE_SOCK_TCP | SWOOLE_SSL; + } + + /** + * Get the underlying TLS configuration + */ + public function getTls(): TLS + { + return $this->tls; + } +} diff --git a/src/Smtp/SmtpConnectionManager.php b/src/Smtp/SmtpConnectionManager.php deleted file mode 100644 index 1d915c7..0000000 --- a/src/Smtp/SmtpConnectionManager.php +++ /dev/null @@ -1,46 +0,0 @@ -dbPool->get(); - - try { - $doc = $db->findOne('smtpServers', [ - Query::equal('domain', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("SMTP server not found for domain: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'smtp-server', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return 'smtp'; - } -} diff --git a/src/Smtp/SmtpServer.php b/src/Smtp/SmtpServer.php deleted file mode 100644 index 9487296..0000000 --- a/src/Smtp/SmtpServer.php +++ /dev/null @@ -1,261 +0,0 @@ -config = array_merge([ - 'host' => $host, - 'port' => $port, - 'workers' => $workers, - 'max_connections' => 50000, - 'max_coroutine' => 50000, - 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB - 'buffer_output_size' => 2 * 1024 * 1024, - 'enable_coroutine' => true, - 'max_wait_time' => 60, - ], $config); - - $this->server = new Server($host, $port, SWOOLE_PROCESS, SWOOLE_SOCK_TCP); - $this->configure(); - } - - protected function configure(): void - { - $this->server->set([ - 'worker_num' => $this->config['workers'], - 'max_connection' => $this->config['max_connections'], - 'max_coroutine' => $this->config['max_coroutine'], - 'socket_buffer_size' => $this->config['socket_buffer_size'], - 'buffer_output_size' => $this->config['buffer_output_size'], - 'enable_coroutine' => $this->config['enable_coroutine'], - 'max_wait_time' => $this->config['max_wait_time'], - - // TCP performance tuning - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - - // SMTP-specific settings - 'open_length_check' => false, // SMTP uses CRLF line endings - 'package_eof' => "\r\n", - 'package_max_length' => 10 * 1024 * 1024, // 10MB max email - - // Enable stats - 'task_enable_coroutine' => true, - ]); - - $this->server->on('start', [$this, 'onStart']); - $this->server->on('workerStart', [$this, 'onWorkerStart']); - $this->server->on('connect', [$this, 'onConnect']); - $this->server->on('receive', [$this, 'onReceive']); - $this->server->on('close', [$this, 'onClose']); - } - - public function onStart(Server $server): void - { - echo "SMTP Proxy Server started at {$this->config['host']}:{$this->config['port']}\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; - } - - public function onWorkerStart(Server $server, int $workerId): void - { - // Initialize connection manager per worker - $this->manager = new SmtpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100 - ); - - echo "Worker #{$workerId} started\n"; - } - - /** - * Handle new SMTP connection - send greeting - */ - public function onConnect(Server $server, int $fd, int $reactorId): void - { - echo "Client #{$fd} connected\n"; - - // Send SMTP greeting - $server->send($fd, "220 appwrite.io ESMTP Proxy\r\n"); - - // Initialize connection state - $server->connections[$fd] = [ - 'state' => 'greeting', - 'domain' => null, - 'backend_fd' => null, - ]; - } - - /** - * Main SMTP command handler - * - * Performance: <1ms per command - */ - public function onReceive(Server $server, int $fd, int $reactorId, string $data): void - { - try { - $conn = &$server->connections[$fd]; - - // Parse SMTP command - $command = strtoupper(substr(trim($data), 0, 4)); - - switch ($command) { - case 'EHLO': - case 'HELO': - $this->handleHelo($server, $fd, $data, $conn); - break; - - case 'MAIL': - case 'RCPT': - case 'DATA': - case 'RSET': - case 'NOOP': - case 'QUIT': - $this->forwardToBackend($server, $fd, $data, $conn); - break; - - default: - $server->send($fd, "500 Unknown command\r\n"); - } - - } catch (\Exception $e) { - echo "Error handling SMTP from #{$fd}: {$e->getMessage()}\n"; - $server->send($fd, "421 Service not available\r\n"); - $server->close($fd); - } - } - - /** - * Handle EHLO/HELO - extract domain and route to backend - */ - protected function handleHelo(Server $server, int $fd, string $data, array &$conn): void - { - // Extract domain from EHLO/HELO command - if (preg_match('/^(EHLO|HELO)\s+([^\s]+)/i', $data, $matches)) { - $domain = $matches[2]; - $conn['domain'] = $domain; - - // Get backend connection - $result = $this->manager->handleConnection($domain); - - // Connect to backend SMTP server - $backendFd = $this->connectToBackend($result->endpoint, 25); - $conn['backend_fd'] = $backendFd; - - // Forward EHLO to backend and relay response - $this->forwardToBackend($server, $fd, $data, $conn); - - } else { - $server->send($fd, "501 Syntax error\r\n"); - } - } - - /** - * Forward command to backend SMTP server - */ - protected function forwardToBackend(Server $server, int $fd, string $data, array &$conn): void - { - if (!isset($conn['backend_fd'])) { - throw new \Exception('No backend connection'); - } - - $backendFd = $conn['backend_fd']; - - // Send to backend - $server->send($backendFd, $data); - - // Relay response back to client (in coroutine) - Coroutine::create(function () use ($server, $fd, $backendFd) { - $response = $server->recv($backendFd, 8192, 5); - - if ($response !== false && $response !== '') { - $server->send($fd, $response); - } - }); - } - - /** - * Connect to backend SMTP server - */ - protected function connectToBackend(string $endpoint, int $port): int - { - [$host, $port] = explode(':', $endpoint . ':' . $port); - $port = (int)$port; - - $client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP); - - if (!$client->connect($host, $port, 30)) { - throw new \Exception("Failed to connect to backend SMTP: {$host}:{$port}"); - } - - // Read backend greeting - $greeting = $client->recv(8192, 5); - - return $client->sock; - } - - public function onClose(Server $server, int $fd, int $reactorId): void - { - echo "Client #{$fd} disconnected\n"; - - // Close backend connection if exists - if (isset($server->connections[$fd]['backend_fd'])) { - $server->close($server->connections[$fd]['backend_fd']); - } - } - - protected function initCache(): \Utopia\Cache\Cache - { - $redis = new \Redis(); - $redis->connect($this->config['redis_host'] ?? '127.0.0.1', $this->config['redis_port'] ?? 6379); - - $adapter = new \Utopia\Cache\Adapter\Redis($redis); - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); - } - - public function start(): void - { - $this->server->start(); - } - - public function getStats(): array - { - return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'coroutines' => Coroutine::stats()['coroutine_num'] ?? 0, - 'manager' => $this->manager?->getStats() ?? [], - ]; - } -} diff --git a/src/Tcp/TcpConnectionManager.php b/src/Tcp/TcpConnectionManager.php deleted file mode 100644 index 2ee8317..0000000 --- a/src/Tcp/TcpConnectionManager.php +++ /dev/null @@ -1,166 +0,0 @@ -port = $port; - } - - protected function identifyResource(string $resourceId): Resource - { - // For TCP: resourceId is database ID extracted from SNI/hostname - $db = $this->dbPool->get(); - - try { - $doc = $db->findOne('databases', [ - Query::equal('hostname', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("Database not found for hostname: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'database', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return $this->port === 5432 ? 'postgresql' : 'mysql'; - } - - /** - * Parse database ID from TCP packet - * - * For PostgreSQL: Extract from SNI or startup message - * For MySQL: Extract from initial handshake - */ - public function parseDatabaseId(string $data, int $fd): string - { - if ($this->port === 5432) { - return $this->parsePostgreSQLDatabaseId($data); - } else { - return $this->parseMySQLDatabaseId($data); - } - } - - /** - * Parse PostgreSQL database ID from startup message - * - * Format: "database\0db-abc123\0" - */ - protected function parsePostgreSQLDatabaseId(string $data): string - { - // PostgreSQL startup message contains database name - if (preg_match('/database\x00([^\x00]+)\x00/', $data, $matches)) { - $dbName = $matches[1]; - - // Extract database ID from format: db-{id}.appwrite.network - if (preg_match('/^db-([a-z0-9]+)/', $dbName, $idMatches)) { - return $idMatches[1]; - } - } - - throw new \Exception('Invalid PostgreSQL database name'); - } - - /** - * Parse MySQL database ID from connection - * - * For MySQL, we typically get the database from subsequent COM_INIT_DB packet - */ - protected function parseMySQLDatabaseId(string $data): string - { - // MySQL COM_INIT_DB packet (0x02) - if (strlen($data) > 5 && ord($data[4]) === 0x02) { - $dbName = substr($data, 5); - - // Extract database ID from format: db-{id} - if (preg_match('/^db-([a-z0-9]+)/', $dbName, $matches)) { - return $matches[1]; - } - } - - throw new \Exception('Invalid MySQL database name'); - } - - /** - * Get or create backend connection - * - * Performance: Reuses connections for same database - */ - public function getBackendConnection(string $databaseId, int $clientFd): int - { - // Check if we already have a connection for this database - $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; - - if (isset($this->backendConnections[$cacheKey])) { - return $this->backendConnections[$cacheKey]; - } - - // Get backend endpoint - $result = $this->handleConnection($databaseId); - - // Create new TCP connection to backend - [$host, $port] = explode(':', $result->endpoint . ':' . $this->port); - $port = (int)$port; - - $client = new Client(SWOOLE_SOCK_TCP); - - if (!$client->connect($host, $port, $this->coldStartTimeout / 1000)) { - throw new \Exception("Failed to connect to backend: {$host}:{$port}"); - } - - // Store backend file descriptor - $backendFd = $client->sock; - $this->backendConnections[$cacheKey] = $backendFd; - - return $backendFd; - } - - /** - * Close backend connection - */ - public function closeBackendConnection(string $databaseId, int $clientFd): void - { - $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; - - if (isset($this->backendConnections[$cacheKey])) { - unset($this->backendConnections[$cacheKey]); - } - } -} diff --git a/src/Tcp/TcpServer.php b/src/Tcp/TcpServer.php deleted file mode 100644 index db1e368..0000000 --- a/src/Tcp/TcpServer.php +++ /dev/null @@ -1,246 +0,0 @@ -ports = $ports; - $this->config = array_merge([ - 'host' => $host, - 'workers' => $workers, - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB for database traffic - 'buffer_output_size' => 8 * 1024 * 1024, - 'enable_coroutine' => true, - 'max_wait_time' => 60, - ], $config); - - // Create main server on first port - $this->server = new Server($host, $ports[0], SWOOLE_PROCESS, SWOOLE_SOCK_TCP); - - // Add listeners for additional ports - for ($i = 1; $i < count($ports); $i++) { - $this->server->addlistener($host, $ports[$i], SWOOLE_SOCK_TCP); - } - - $this->configure(); - } - - protected function configure(): void - { - $this->server->set([ - 'worker_num' => $this->config['workers'], - 'max_connection' => $this->config['max_connections'], - 'max_coroutine' => $this->config['max_coroutine'], - 'socket_buffer_size' => $this->config['socket_buffer_size'], - 'buffer_output_size' => $this->config['buffer_output_size'], - 'enable_coroutine' => $this->config['enable_coroutine'], - 'max_wait_time' => $this->config['max_wait_time'], - - // TCP performance tuning - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - 'open_tcp_keepalive' => true, - 'tcp_keepidle' => 4, - 'tcp_keepinterval' => 5, - 'tcp_keepcount' => 5, - - // Package settings for database protocols - 'open_length_check' => false, // Let database handle framing - 'package_max_length' => 8 * 1024 * 1024, // 8MB max query - - // Enable stats - 'task_enable_coroutine' => true, - ]); - - $this->server->on('start', [$this, 'onStart']); - $this->server->on('workerStart', [$this, 'onWorkerStart']); - $this->server->on('connect', [$this, 'onConnect']); - $this->server->on('receive', [$this, 'onReceive']); - $this->server->on('close', [$this, 'onClose']); - } - - public function onStart(Server $server): void - { - echo "TCP Proxy Server started at {$this->config['host']}\n"; - echo "Ports: " . implode(', ', $this->ports) . "\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; - } - - public function onWorkerStart(Server $server, int $workerId): void - { - // Initialize connection manager per worker per port - foreach ($this->ports as $port) { - $this->managers[$port] = new TcpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100, - port: $port - ); - } - - echo "Worker #{$workerId} started\n"; - } - - /** - * Handle new TCP connection - */ - public function onConnect(Server $server, int $fd, int $reactorId): void - { - $info = $server->getClientInfo($fd); - $port = $info['server_port'] ?? 0; - - echo "Client #{$fd} connected to port {$port}\n"; - } - - /** - * Main receive handler - FAST AS FUCK - * - * Performance: <1ms overhead for proxying - */ - public function onReceive(Server $server, int $fd, int $reactorId, string $data): void - { - $startTime = microtime(true); - - try { - $info = $server->getClientInfo($fd); - $port = $info['server_port'] ?? 0; - - $manager = $this->managers[$port] ?? null; - if (!$manager) { - throw new \Exception("No manager for port {$port}"); - } - - // Parse database ID from initial packet (SNI or first query) - $databaseId = $manager->parseDatabaseId($data, $fd); - - // Get or create backend connection - $backendFd = $manager->getBackendConnection($databaseId, $fd); - - // Forward data to backend using zero-copy where possible - $this->forwardToBackend($server, $fd, $backendFd, $data); - - // Start bidirectional forwarding in coroutine - if (!isset($server->connections[$fd]['forwarding'])) { - $server->connections[$fd]['forwarding'] = true; - $this->startForwarding($server, $fd, $backendFd); - } - - } catch (\Exception $e) { - echo "Error handling data from #{$fd}: {$e->getMessage()}\n"; - $server->close($fd); - } - } - - /** - * Bidirectional forwarding loop - ZERO-COPY - * - * Performance: 10GB/s+ throughput - */ - protected function startForwarding(Server $server, int $clientFd, int $backendFd): void - { - Coroutine::create(function () use ($server, $clientFd, $backendFd) { - // Forward client -> backend - while ($server->exist($clientFd) && $server->exist($backendFd)) { - $data = $server->recv($clientFd, 65536, 0.1); - - if ($data === false || $data === '') { - break; - } - - $server->send($backendFd, $data); - } - }); - - Coroutine::create(function () use ($server, $clientFd, $backendFd) { - // Forward backend -> client - while ($server->exist($clientFd) && $server->exist($backendFd)) { - $data = $server->recv($backendFd, 65536, 0.1); - - if ($data === false || $data === '') { - break; - } - - $server->send($clientFd, $data); - } - }); - } - - protected function forwardToBackend(Server $server, int $clientFd, int $backendFd, string $data): void - { - $server->send($backendFd, $data); - } - - public function onClose(Server $server, int $fd, int $reactorId): void - { - echo "Client #{$fd} disconnected\n"; - - // Close backend connection if exists - if (isset($server->connections[$fd]['backend_fd'])) { - $server->close($server->connections[$fd]['backend_fd']); - } - } - - protected function initCache(): \Utopia\Cache\Cache - { - $redis = new \Redis(); - $redis->connect($this->config['redis_host'] ?? '127.0.0.1', $this->config['redis_port'] ?? 6379); - - $adapter = new \Utopia\Cache\Adapter\Redis($redis); - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); - } - - public function start(): void - { - $this->server->start(); - } - - public function getStats(): array - { - $managerStats = []; - foreach ($this->managers as $port => $manager) { - $managerStats[$port] = $manager->getStats(); - } - - return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'coroutines' => Coroutine::stats()['coroutine_num'] ?? 0, - 'managers' => $managerStats, - ]; - } -} diff --git a/tests/AdapterActionsTest.php b/tests/AdapterActionsTest.php new file mode 100644 index 0000000..74c8098 --- /dev/null +++ b/tests/AdapterActionsTest.php @@ -0,0 +1,125 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testResolverIsAssignedToAdapters(): void + { + $http = new Adapter($this->resolver, protocol: Protocol::HTTP); + $tcp = new TCPAdapter(port: 5432, resolver: $this->resolver); + $smtp = new Adapter($this->resolver, protocol: Protocol::SMTP); + + $this->assertSame($this->resolver, $http->resolver); + $this->assertSame($this->resolver, $tcp->resolver); + $this->assertSame($this->resolver, $smtp->resolver); + } + + public function testResolveRoutesAndReturnsEndpoint(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $result = $adapter->route('api.example.com'); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(Protocol::HTTP, $result->protocol); + } + + public function testNotifyConnectDelegatesToResolver(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $adapter->notifyConnect('resource-123', ['extra' => 'data']); + + $connects = $this->resolver->getConnects(); + $this->assertCount(1, $connects); + $this->assertSame('resource-123', $connects[0]['resourceId']); + $this->assertSame(['extra' => 'data'], $connects[0]['metadata']); + } + + public function testNotifyCloseDelegatesToResolver(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $adapter->notifyClose('resource-123', ['extra' => 'data']); + + $disconnects = $this->resolver->getDisconnects(); + $this->assertCount(1, $disconnects); + $this->assertSame('resource-123', $disconnects[0]['resourceId']); + $this->assertSame(['extra' => 'data'], $disconnects[0]['metadata']); + } + + public function testTrackActivityDelegatesToResolverWithThrottling(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setInterval(1); // 1 second throttle + + // First call should trigger activity tracking + $adapter->track('resource-123'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Immediate second call should be throttled + $adapter->track('resource-123'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Wait for throttle interval to pass + sleep(2); + + // Third call should trigger activity tracking + $adapter->track('resource-123'); + $this->assertCount(2, $this->resolver->getActivities()); + } + + public function testRoutingErrorThrowsException(): void + { + $this->resolver->setException(new ResolverException('No backend found')); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('No backend found'); + + $adapter->route('api.example.com'); + } + + public function testEmptyEndpointThrowsException(): void + { + $this->resolver->setEndpoint(''); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Resolver returned empty endpoint'); + + $adapter->route('api.example.com'); + } + + public function testSkipValidationAllowsPrivateIPs(): void + { + // 10.0.0.1 is a private IP that would normally be blocked + $this->resolver->setEndpoint('10.0.0.1:8080'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + // Should not throw exception with validation disabled + $result = $adapter->route('api.example.com'); + $this->assertSame('10.0.0.1:8080', $result->endpoint); + } +} diff --git a/tests/AdapterByteTrackingTest.php b/tests/AdapterByteTrackingTest.php new file mode 100644 index 0000000..3465db4 --- /dev/null +++ b/tests/AdapterByteTrackingTest.php @@ -0,0 +1,231 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testRecordBytesInitializesCounters(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + + // Verify via notifyClose which flushes byte counters + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesAccumulatesValues(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->recordBytes('resource-1', inbound: 50, outbound: 75); + $adapter->recordBytes('resource-1', inbound: 25, outbound: 25); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(175, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(300, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesDefaultsToZero(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1'); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(0, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(0, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesInboundOnly(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 500); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(500, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(0, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesOutboundOnly(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', outbound: 300); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(0, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(300, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesTracksMultipleResources(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->recordBytes('resource-2', inbound: 300, outbound: 400); + + $adapter->notifyClose('resource-1'); + $adapter->notifyClose('resource-2'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + $this->assertSame(300, $disconnects[1]['metadata']['inboundBytes']); + $this->assertSame(400, $disconnects[1]['metadata']['outboundBytes']); + } + + public function testNotifyCloseFlushesAndClearsCounters(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->notifyClose('resource-1'); + + // Second close should not include byte data + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertArrayHasKey('inboundBytes', $disconnects[0]['metadata']); + $this->assertArrayNotHasKey('inboundBytes', $disconnects[1]['metadata']); + } + + public function testNotifyCloseWithoutByteRecordingOmitsByteMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->notifyClose('resource-1', ['reason' => 'timeout']); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertArrayNotHasKey('inboundBytes', $disconnects[0]['metadata']); + $this->assertSame('timeout', $disconnects[0]['metadata']['reason']); + } + + public function testNotifyCloseMergesByteDataWithExistingMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->notifyClose('resource-1', ['reason' => 'client_disconnect']); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + $this->assertSame('client_disconnect', $disconnects[0]['metadata']['reason']); + } + + public function testTrackFlushesAccumulatedBytes(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + $adapter->setInterval(0); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->track('resource-1'); + + $activities = $this->resolver->getActivities(); + $this->assertCount(1, $activities); + $this->assertSame(100, $activities[0]['metadata']['inboundBytes']); + $this->assertSame(200, $activities[0]['metadata']['outboundBytes']); + } + + public function testTrackResetsCountersAfterFlush(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + $adapter->setInterval(0); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->track('resource-1'); + + // Record more bytes and track again + $adapter->recordBytes('resource-1', inbound: 50, outbound: 25); + + // Need to wait for throttle to pass (interval is 0 but time() is same second) + // Force a new second + sleep(1); + $adapter->track('resource-1'); + + $activities = $this->resolver->getActivities(); + $this->assertCount(2, $activities); + $this->assertSame(50, $activities[1]['metadata']['inboundBytes']); + $this->assertSame(25, $activities[1]['metadata']['outboundBytes']); + } + + public function testTrackWithoutBytesOmitsByteMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + $adapter->setInterval(0); + + $adapter->track('resource-1', ['type' => 'query']); + + $activities = $this->resolver->getActivities(); + $this->assertCount(1, $activities); + $this->assertArrayNotHasKey('inboundBytes', $activities[0]['metadata']); + $this->assertSame('query', $activities[0]['metadata']['type']); + } + + public function testNotifyCloseClearsActivityTimestamp(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + $adapter->setInterval(9999); + + // Track once to set the timestamp + $adapter->track('resource-1'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Normally this would be throttled + $adapter->track('resource-1'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Close clears the timestamp + $adapter->notifyClose('resource-1'); + + // Now tracking should work again immediately + $adapter->track('resource-1'); + $this->assertCount(2, $this->resolver->getActivities()); + } + + public function testSetActivityIntervalReturnsSelf(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $result = $adapter->setInterval(60); + $this->assertSame($adapter, $result); + } + + public function testSetSkipValidationReturnsSelf(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::TCP); + + $result = $adapter->setSkipValidation(true); + $this->assertSame($adapter, $result); + } +} diff --git a/tests/AdapterFactoryTest.php b/tests/AdapterFactoryTest.php new file mode 100644 index 0000000..9a5f917 --- /dev/null +++ b/tests/AdapterFactoryTest.php @@ -0,0 +1,91 @@ +markTestSkipped('ext-swoole is required to run Config tests.'); + } + } + + public function testDefaultAdapterFactoryIsNull(): void + { + $config = new Config(ports: [5432]); + $this->assertNull($config->adapterFactory); + } + + public function testAdapterFactoryAcceptsClosure(): void + { + $factory = function (int $port) { + return 'adapter-for-port-' . $port; + }; + + $config = new Config(ports: [5432], adapterFactory: $factory); + $this->assertNotNull($config->adapterFactory); + $this->assertInstanceOf(\Closure::class, $config->adapterFactory); + } + + public function testAdapterFactoryClosureIsInvokable(): void + { + $factory = function (int $port): string { + return 'adapter-for-port-' . $port; + }; + + $config = new Config(ports: [5432], adapterFactory: $factory); + $callable = $config->adapterFactory; + \assert($callable !== null); + $result = $callable(5432); + $this->assertSame('adapter-for-port-5432', $result); + } + + public function testAdapterFactoryClosureReceivesPort(): void + { + $receivedPorts = []; + $factory = function (int $port) use (&$receivedPorts): string { + $receivedPorts[] = $port; + return 'adapter'; + }; + + $config = new Config(ports: [5432], adapterFactory: $factory); + $callable = $config->adapterFactory; + \assert($callable !== null); + $callable(5432); + $callable(3306); + $callable(27017); + + $this->assertSame([5432, 3306, 27017], $receivedPorts); + } + + public function testOtherConfigValuesPreservedWithFactory(): void + { + $factory = function (int $port) { + return 'adapter'; + }; + + $config = new Config( + host: '127.0.0.1', + ports: [5432], + workers: 8, + adapterFactory: $factory, + ); + + $this->assertSame('127.0.0.1', $config->host); + $this->assertSame([5432], $config->ports); + $this->assertSame(8, $config->workers); + $this->assertNotNull($config->adapterFactory); + } + + public function testNullAdapterFactoryPreservesDefaults(): void + { + $config = new Config(ports: [5432], adapterFactory: null); + $this->assertNull($config->adapterFactory); + $this->assertSame('0.0.0.0', $config->host); + $this->assertSame([5432], $config->ports); + } +} diff --git a/tests/AdapterMetadataTest.php b/tests/AdapterMetadataTest.php new file mode 100644 index 0000000..a969c44 --- /dev/null +++ b/tests/AdapterMetadataTest.php @@ -0,0 +1,44 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testHttpAdapterMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $this->assertSame(Protocol::HTTP, $adapter->getProtocol()); + } + + public function testSmtpAdapterMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::SMTP); + + $this->assertSame(Protocol::SMTP, $adapter->getProtocol()); + } + + public function testTcpAdapterMetadata(): void + { + $adapter = new TCPAdapter(port: 5432, resolver: $this->resolver); + + $this->assertSame(Protocol::PostgreSQL, $adapter->getProtocol()); + $this->assertSame(5432, $adapter->port); + } +} diff --git a/tests/AdapterStatsTest.php b/tests/AdapterStatsTest.php new file mode 100644 index 0000000..a8fcb1c --- /dev/null +++ b/tests/AdapterStatsTest.php @@ -0,0 +1,83 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testCacheHitUpdatesStats(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('api.example.com'); + $second = $adapter->route('api.example.com'); + + $this->assertFalse($first->metadata['cached']); + $this->assertTrue($second->metadata['cached']); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['connections']); + $this->assertSame(1, $stats['cacheHits']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(50.0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingErrors']); + $this->assertSame(1, $stats['routingTableSize']); + $this->assertGreaterThan(0, $stats['routingTableMemory']); + } + + public function testRoutingErrorIncrementsStats(): void + { + $this->resolver->setException(new ResolverException('No backend')); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + try { + $adapter->route('api.example.com'); + $this->fail('Expected routing error was not thrown.'); + } catch (ResolverException $e) { + $this->assertSame('No backend', $e->getMessage()); + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(0.0, $stats['cacheHitRate']); + } + + public function testResolverStatsAreIncludedInAdapterStats(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $adapter->route('api.example.com'); + + $stats = $adapter->getStats(); + $this->assertArrayHasKey('resolver', $stats); + $this->assertIsArray($stats['resolver']); + $this->assertSame('mock', $stats['resolver']['resolver']); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..12e1c90 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,222 @@ +markTestSkipped('ext-swoole is required to run Config tests.'); + } + } + + public function testDefaultHost(): void + { + $config = new Config(ports: [5432]); + $this->assertSame('0.0.0.0', $config->host); + } + + public function testPortsAreRequired(): void + { + $config = new Config(ports: [5432, 3306]); + $this->assertSame([5432, 3306], $config->ports); + } + + public function testDefaultWorkers(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(16, $config->workers); + } + + public function testDefaultMaxConnections(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(200_000, $config->maxConnections); + } + + public function testDefaultMaxCoroutine(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(200_000, $config->maxCoroutine); + } + + public function testDefaultBufferSizes(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(16 * 1024 * 1024, $config->socketBufferSize); + $this->assertSame(16 * 1024 * 1024, $config->bufferOutputSize); + } + + public function testDefaultReactorNumIsCpuBased(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(swoole_cpu_num() * 2, $config->reactorNum); + } + + public function testDefaultDispatchMode(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(2, $config->dispatchMode); + } + + public function testDefaultEnableReusePort(): void + { + $config = new Config(ports: [5432]); + $this->assertTrue($config->enableReusePort); + } + + public function testDefaultBacklog(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(65535, $config->backlog); + } + + public function testDefaultPackageMaxLength(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(32 * 1024 * 1024, $config->packageMaxLength); + } + + public function testDefaultTcpKeepaliveSettings(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(30, $config->tcpKeepidle); + $this->assertSame(10, $config->tcpKeepinterval); + $this->assertSame(3, $config->tcpKeepcount); + } + + public function testDefaultEnableCoroutine(): void + { + $config = new Config(ports: [5432]); + $this->assertTrue($config->enableCoroutine); + } + + public function testDefaultMaxWaitTime(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(60, $config->maxWaitTime); + } + + public function testDefaultLogLevel(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(SWOOLE_LOG_ERROR, $config->logLevel); + } + + public function testDefaultLogConnections(): void + { + $config = new Config(ports: [5432]); + $this->assertFalse($config->logConnections); + } + + public function testDefaultReceiveBufferSize(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(131072, $config->receiveBufferSize); + } + + public function testDefaultBackendConnectTimeout(): void + { + $config = new Config(ports: [5432]); + $this->assertSame(5.0, $config->connectTimeout); + } + + public function testDefaultSkipValidation(): void + { + $config = new Config(ports: [5432]); + $this->assertFalse($config->skipValidation); + } + + public function testDefaultTlsIsNull(): void + { + $config = new Config(ports: [5432]); + $this->assertNull($config->tls); + } + + public function testCustomReactorNum(): void + { + $config = new Config(ports: [5432], reactorNum: 4); + $this->assertSame(4, $config->reactorNum); + } + + public function testCustomPorts(): void + { + $config = new Config(ports: [5432]); + $this->assertSame([5432], $config->ports); + } + + public function testCustomHost(): void + { + $config = new Config(ports: [5432], host: '127.0.0.1'); + $this->assertSame('127.0.0.1', $config->host); + } + + public function testCustomWorkers(): void + { + $config = new Config(ports: [5432], workers: 4); + $this->assertSame(4, $config->workers); + } + + public function testCustomBackendConnectTimeout(): void + { + $config = new Config(ports: [5432], connectTimeout: 10.5); + $this->assertSame(10.5, $config->connectTimeout); + } + + public function testCustomSkipValidation(): void + { + $config = new Config(ports: [5432], skipValidation: true); + $this->assertTrue($config->skipValidation); + } + + public function testCustomLogConnections(): void + { + $config = new Config(ports: [5432], logConnections: true); + $this->assertTrue($config->logConnections); + } + + public function testIsTlsEnabledFalseByDefault(): void + { + $config = new Config(ports: [5432]); + $this->assertFalse($config->isTlsEnabled()); + } + + public function testIsTlsEnabledTrueWhenConfigured(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $config = new Config(ports: [5432], tls: $tls); + $this->assertTrue($config->isTlsEnabled()); + } + + public function testGetTLSContextNullByDefault(): void + { + $config = new Config(ports: [5432]); + $this->assertNull($config->getTLSContext()); + } + + public function testGetTLSContextReturnsInstanceWhenConfigured(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $config = new Config(ports: [5432], tls: $tls); + + $context = $config->getTLSContext(); + $this->assertInstanceOf(TLSContext::class, $context); + $this->assertSame($tls, $context->getTls()); + } + + public function testGetTLSContextReturnsNewInstanceEachCall(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $config = new Config(ports: [5432], tls: $tls); + + $context1 = $config->getTLSContext(); + $context2 = $config->getTLSContext(); + $this->assertNotSame($context1, $context2); + } +} diff --git a/tests/ConnectionResultExtendedTest.php b/tests/ConnectionResultExtendedTest.php new file mode 100644 index 0000000..e3ca78f --- /dev/null +++ b/tests/ConnectionResultExtendedTest.php @@ -0,0 +1,92 @@ +assertSame($protocol, $result->protocol); + } + } + + public function testDefaultEmptyMetadata(): void + { + $result = new ConnectionResult( + endpoint: '127.0.0.1:8080', + protocol: Protocol::HTTP, + ); + + $this->assertSame([], $result->metadata); + } + + public function testMetadataWithMultipleTypes(): void + { + $result = new ConnectionResult( + endpoint: '127.0.0.1:8080', + protocol: Protocol::HTTP, + metadata: [ + 'cached' => true, + 'latency' => 1.5, + 'count' => 42, + 'tags' => ['fast', 'reliable'], + 'config' => ['timeout' => 30], + ] + ); + + $this->assertTrue($result->metadata['cached']); + $this->assertSame(1.5, $result->metadata['latency']); + $this->assertSame(42, $result->metadata['count']); + $this->assertSame(['fast', 'reliable'], $result->metadata['tags']); + $this->assertSame(['timeout' => 30], $result->metadata['config']); + } + + public function testEndpointWithHostOnly(): void + { + $result = new ConnectionResult( + endpoint: 'db.example.com', + protocol: Protocol::PostgreSQL, + ); + + $this->assertSame('db.example.com', $result->endpoint); + } + + public function testEndpointWithHostAndPort(): void + { + $result = new ConnectionResult( + endpoint: 'db.example.com:5432', + protocol: Protocol::PostgreSQL, + ); + + $this->assertSame('db.example.com:5432', $result->endpoint); + } + + public function testEndpointWithIpAddress(): void + { + $result = new ConnectionResult( + endpoint: '192.168.1.100:3306', + protocol: Protocol::MySQL, + ); + + $this->assertSame('192.168.1.100:3306', $result->endpoint); + } +} diff --git a/tests/ConnectionResultTest.php b/tests/ConnectionResultTest.php new file mode 100644 index 0000000..8b8ca80 --- /dev/null +++ b/tests/ConnectionResultTest.php @@ -0,0 +1,23 @@ + false] + ); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(Protocol::HTTP, $result->protocol); + $this->assertSame(['cached' => false], $result->metadata); + } +} diff --git a/tests/EndpointValidationTest.php b/tests/EndpointValidationTest.php new file mode 100644 index 0000000..5a9f57d --- /dev/null +++ b/tests/EndpointValidationTest.php @@ -0,0 +1,261 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + private function createAdapter(): Adapter + { + return new Adapter($this->resolver, protocol: Protocol::HTTP); + } + + public function testRejectsEndpointWithMultipleColons(): void + { + $this->resolver->setEndpoint('host:port:extra'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid endpoint format'); + + $adapter->route('test'); + } + + public function testRejectsPortAbove65535(): void + { + $this->resolver->setEndpoint('example.com:70000'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid port number'); + + $adapter->route('test'); + } + + public function testRejectsPortWayAboveLimit(): void + { + $this->resolver->setEndpoint('example.com:999999'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid port number'); + + $adapter->route('test'); + } + + public function testRejects10Network(): void + { + $this->resolver->setEndpoint('10.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects10NetworkHighEnd(): void + { + $this->resolver->setEndpoint('10.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects172Network(): void + { + $this->resolver->setEndpoint('172.16.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects172NetworkHighEnd(): void + { + $this->resolver->setEndpoint('172.31.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects192168Network(): void + { + $this->resolver->setEndpoint('192.168.1.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLoopbackIp(): void + { + $this->resolver->setEndpoint('127.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLoopbackHighEnd(): void + { + $this->resolver->setEndpoint('127.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLinkLocal(): void + { + $this->resolver->setEndpoint('169.254.1.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsMulticast(): void + { + $this->resolver->setEndpoint('224.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsMulticastHighEnd(): void + { + $this->resolver->setEndpoint('239.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsReservedRange240(): void + { + $this->resolver->setEndpoint('240.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsZeroNetwork(): void + { + $this->resolver->setEndpoint('0.0.0.0:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testAcceptsPublicIp(): void + { + // 8.8.8.8 is Google's public DNS + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8:80', $result->endpoint); + } + + public function testAcceptsPublicIpWithoutPort(): void + { + $this->resolver->setEndpoint('8.8.8.8'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8', $result->endpoint); + } + + public function testSkipValidationAllowsPrivateIps(): void + { + $this->resolver->setEndpoint('10.0.0.1:80'); + $adapter = $this->createAdapter(); + $adapter->setSkipValidation(true); + + $result = $adapter->route('test'); + $this->assertSame('10.0.0.1:80', $result->endpoint); + } + + public function testSkipValidationAllowsLoopback(): void + { + $this->resolver->setEndpoint('127.0.0.1:80'); + $adapter = $this->createAdapter(); + $adapter->setSkipValidation(true); + + $result = $adapter->route('test'); + $this->assertSame('127.0.0.1:80', $result->endpoint); + } + + public function testRejectsUnresolvableHostname(): void + { + $this->resolver->setEndpoint('this-hostname-definitely-does-not-exist-12345.invalid:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Cannot resolve hostname'); + + $adapter->route('test'); + } + + public function testAcceptsPort65535(): void + { + $this->resolver->setEndpoint('8.8.8.8:65535'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8:65535', $result->endpoint); + } + + public function testAcceptsPortZeroImplicit(): void + { + // No port specified resolves to 0 which is <= 65535 + $this->resolver->setEndpoint('8.8.8.8'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8', $result->endpoint); + } +} diff --git a/tests/Integration/EdgeIntegrationTest.php b/tests/Integration/EdgeIntegrationTest.php new file mode 100644 index 0000000..50c70ad --- /dev/null +++ b/tests/Integration/EdgeIntegrationTest.php @@ -0,0 +1,679 @@ +markTestSkipped('ext-swoole is required to run integration tests.'); + } + } + + /** + * @group integration + */ + public function testEdgeResolverResolvesDatabaseIdToEndpoint(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('abc123', [ + 'host' => '10.0.1.50', + 'port' => 5432, + 'username' => 'appwrite_user', + 'password' => 'secret_password', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + $result = $adapter->route('abc123'); + + $this->assertInstanceOf(ConnectionResult::class, $result); + $this->assertSame('10.0.1.50:5432', $result->endpoint); + $this->assertSame(Protocol::PostgreSQL, $result->protocol); + $this->assertSame('abc123', $result->metadata['resourceId']); + $this->assertSame('appwrite_user', $result->metadata['username']); + $this->assertFalse($result->metadata['cached']); + } + + /** + * @group integration + */ + public function testEdgeResolverReturnsNotFoundForUnknownDatabase(): void + { + $resolver = new EdgeMockResolver(); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(ResolverException::NOT_FOUND); + + $adapter->route('nonexistent'); + } + + /** + * @group integration + */ + public function testResolverReceivesRawDataForRouting(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('raw-packet-data', [ + 'host' => '10.0.1.50', + 'port' => 5432, + 'username' => 'user1', + 'password' => 'pass1', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + // The resolver receives the raw data directly and routes based on it + $result = $adapter->route('raw-packet-data'); + $this->assertSame('10.0.1.50:5432', $result->endpoint); + } + + /** + * @group integration + */ + public function testFailoverResolverUsesSecondaryOnPrimaryFailure(): void + { + $primaryResolver = new EdgeMockResolver(); + // Primary has no databases registered, so it will throw NOT_FOUND + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('faildb', [ + 'host' => '10.0.2.50', + 'port' => 5432, + 'username' => 'failover_user', + 'password' => 'failover_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter(port: 5432, resolver: $failoverResolver); + $adapter->setSkipValidation(true); + + $result = $adapter->route('faildb'); + + $this->assertSame('10.0.2.50:5432', $result->endpoint); + $this->assertTrue($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testFailoverResolverUsesPrimaryWhenAvailable(): void + { + $primaryResolver = new EdgeMockResolver(); + $primaryResolver->registerDatabase('okdb', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'primary_user', + 'password' => 'primary_pass', + ]); + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('okdb', [ + 'host' => '10.0.2.50', + 'port' => 5432, + 'username' => 'secondary_user', + 'password' => 'secondary_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter(port: 5432, resolver: $failoverResolver); + $adapter->setSkipValidation(true); + + $result = $adapter->route('okdb'); + + $this->assertSame('10.0.1.10:5432', $result->endpoint); + $this->assertFalse($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testFailoverResolverPropagatesErrorWhenBothFail(): void + { + $primaryResolver = new EdgeMockResolver(); + $secondaryResolver = new EdgeMockResolver(); + // Neither has databases registered + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter(port: 5432, resolver: $failoverResolver); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(ResolverException::NOT_FOUND); + + $adapter->route('nowhere'); + } + + /** + * @group integration + */ + public function testFailoverResolverHandlesUnavailablePrimary(): void + { + $primaryResolver = new EdgeMockResolver(); + $primaryResolver->setUnavailable(true); + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('unavaildb', [ + 'host' => '10.0.3.10', + 'port' => 5432, + 'username' => 'backup_user', + 'password' => 'backup_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter(port: 5432, resolver: $failoverResolver); + $adapter->setSkipValidation(true); + + $result = $adapter->route('unavaildb'); + + $this->assertSame('10.0.3.10:5432', $result->endpoint); + $this->assertTrue($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testRoutingCacheReturnsCachedResultOnRepeat(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('cachedb', [ + 'host' => '10.0.4.10', + 'port' => 5432, + 'username' => 'cached_user', + 'password' => 'cached_pass', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + // Ensure we are at the start of a fresh second so both calls + // land within the same cache window + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('cachedb'); + $this->assertFalse($first->metadata['cached']); + + $second = $adapter->route('cachedb'); + $this->assertTrue($second->metadata['cached']); + + $this->assertSame($first->endpoint, $second->endpoint); + $this->assertSame(1, $resolver->getResolveCount()); + } + + /** + * @group integration + */ + public function testCacheInvalidationForcesReResolve(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('invaldb', [ + 'host' => '10.0.4.20', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(1); + + // Align to second boundary + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('invaldb'); + $this->assertFalse($first->metadata['cached']); + + $resolver->purge('invaldb'); + + // Wait for the routing table cache to expire + sleep(2); + + $second = $adapter->route('invaldb'); + $this->assertFalse($second->metadata['cached']); + + // Should have resolved twice + $this->assertSame(2, $resolver->getResolveCount()); + } + + /** + * @group integration + */ + public function testDifferentDatabasesResolveIndependently(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('db1', [ + 'host' => '10.0.5.1', + 'port' => 5432, + 'username' => 'user1', + 'password' => 'pass1', + ]); + $resolver->registerDatabase('db2', [ + 'host' => '10.0.5.2', + 'port' => 5432, + 'username' => 'user2', + 'password' => 'pass2', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + $result1 = $adapter->route('db1'); + $result2 = $adapter->route('db2'); + + $this->assertSame('10.0.5.1:5432', $result1->endpoint); + $this->assertSame('10.0.5.2:5432', $result2->endpoint); + $this->assertNotSame($result1->endpoint, $result2->endpoint); + } + + /** + * @group integration + */ + public function testConcurrentResolutionOfMultipleDatabases(): void + { + $resolver = new EdgeMockResolver(); + $databaseCount = 20; + + for ($i = 1; $i <= $databaseCount; $i++) { + $resolver->registerDatabase("concurrent{$i}", [ + 'host' => "10.0.10.{$i}", + 'port' => 5432, + 'username' => "user_{$i}", + 'password' => "pass_{$i}", + ]); + } + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $results = []; + for ($i = 1; $i <= $databaseCount; $i++) { + $results[$i] = $adapter->route("concurrent{$i}"); + } + + for ($i = 1; $i <= $databaseCount; $i++) { + $this->assertSame("10.0.10.{$i}:5432", $results[$i]->endpoint); + $this->assertSame(Protocol::PostgreSQL, $results[$i]->protocol); + } + + $stats = $adapter->getStats(); + $this->assertSame($databaseCount, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame($databaseCount, $stats['routingTableSize']); + } + + /** + * @group integration + */ + public function testConcurrentResolutionWithMixedSuccessAndFailure(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('gooddb1', [ + 'host' => '10.0.11.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerDatabase('gooddb2', [ + 'host' => '10.0.11.2', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + // 'baddb' is intentionally not registered + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + $result1 = $adapter->route('gooddb1'); + $this->assertSame('10.0.11.1:5432', $result1->endpoint); + + $result2 = $adapter->route('gooddb2'); + $this->assertSame('10.0.11.2:5432', $result2->endpoint); + + try { + $adapter->route('baddb'); + $this->fail('Expected ResolverException for unknown database'); + } catch (ResolverException $e) { + $this->assertSame(ResolverException::NOT_FOUND, $e->getCode()); + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + $this->assertSame(2, $stats['connections']); + } + + /** + * @group integration + */ + public function testConnectAndDisconnectLifecycleTracked(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('lifecycle1', [ + 'host' => '10.0.6.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + + // Resolve the database + $adapter->route('lifecycle1'); + + // Notify connect + $adapter->notifyConnect('lifecycle1', ['clientFd' => 1]); + $this->assertCount(1, $resolver->getConnects()); + $this->assertSame('lifecycle1', $resolver->getConnects()[0]['resourceId']); + + // Track activity + $adapter->setInterval(0); + $adapter->track('lifecycle1', ['query' => 'SELECT 1']); + $this->assertCount(1, $resolver->getActivities()); + + // Notify disconnect + $adapter->notifyClose('lifecycle1', ['clientFd' => 1]); + $this->assertCount(1, $resolver->getDisconnects()); + $this->assertSame('lifecycle1', $resolver->getDisconnects()[0]['resourceId']); + } + + /** + * @group integration + */ + public function testStatsAggregateAcrossOperations(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('statsdb', [ + 'host' => '10.0.7.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter(port: 5432, resolver: $resolver); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + // Align to second boundary + $start = time(); + while (time() === $start) { + usleep(1000); + } + + // Perform multiple operations + $adapter->route('statsdb'); // miss + $adapter->route('statsdb'); // hit + $adapter->route('statsdb'); // hit + + $adapter->notifyConnect('statsdb'); + $adapter->notifyClose('statsdb'); + + $stats = $adapter->getStats(); + + $this->assertSame('TCP', $stats['adapter']); + $this->assertSame('postgresql', $stats['protocol']); + $this->assertSame(3, $stats['connections']); + $this->assertSame(2, $stats['cacheHits']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertGreaterThan(0.0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingErrors']); + + /** @var array $resolverStats */ + $resolverStats = $stats['resolver']; + $this->assertSame(1, $resolverStats['connects']); + $this->assertSame(1, $resolverStats['disconnects']); + } + +} + +/** + * Simulates an Edge service resolver that resolves resource IDs to backend + * endpoints. In production, the resolve() call would be an HTTP request to + * the Edge service. Here we simulate that with an in-memory registry. + */ +class EdgeMockResolver implements Resolver +{ + /** @var array */ + protected array $databases = []; + + /** @var array}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + protected int $resolveCount = 0; + + protected bool $unavailable = false; + + /** + * Register a database endpoint (simulates Edge service configuration) + * + * @param array{host: string, port: int, username: string, password: string} $config + */ + public function registerDatabase(string $resourceId, array $config): self + { + $this->databases[$resourceId] = $config; + + return $this; + } + + public function setUnavailable(bool $unavailable): self + { + $this->unavailable = $unavailable; + + return $this; + } + + public function resolve(string $resourceId): Result + { + if ($this->unavailable) { + throw new ResolverException( + "Edge service unavailable", + ResolverException::UNAVAILABLE, + ['resourceId' => $resourceId] + ); + } + + if (!isset($this->databases[$resourceId])) { + throw new ResolverException( + "Database not found: {$resourceId}", + ResolverException::NOT_FOUND, + ['resourceId' => $resourceId] + ); + } + + $this->resolveCount++; + $config = $this->databases[$resourceId]; + + return new Result( + endpoint: "{$config['host']}:{$config['port']}", + metadata: [ + 'resourceId' => $resourceId, + 'username' => $config['username'], + ] + ); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'edge-mock', + 'connects' => count($this->connects), + 'disconnects' => count($this->disconnects), + 'activities' => count($this->activities), + 'resolveCount' => $this->resolveCount, + ]; + } + + public function getResolveCount(): int + { + return $this->resolveCount; + } + + /** @return array}> */ + public function getConnects(): array + { + return $this->connects; + } + + /** @return array}> */ + public function getDisconnects(): array + { + return $this->disconnects; + } + + /** @return array}> */ + public function getActivities(): array + { + return $this->activities; + } +} + +/** + * Failover resolver that tries a primary resolver first and falls back + * to a secondary resolver if the primary fails. This simulates the + * production pattern where the Edge service might be unavailable and + * a secondary backend provides resilience. + */ +class EdgeFailoverResolver implements Resolver +{ + protected bool $failedOver = false; + + /** @var array}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + public function __construct( + protected Resolver $primary, + protected Resolver $secondary + ) { + } + + public function resolve(string $resourceId): Result + { + $this->failedOver = false; + + try { + return $this->primary->resolve($resourceId); + } catch (ResolverException $e) { + $this->failedOver = true; + + // Try secondary; let its exception propagate if it also fails + return $this->secondary->resolve($resourceId); + } + } + + public function didFailover(): bool + { + return $this->failedOver; + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + $this->primary->purge($resourceId); + $this->secondary->purge($resourceId); + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'edge-failover', + 'failedOver' => $this->failedOver, + 'primary' => $this->primary->getStats(), + 'secondary' => $this->secondary->getStats(), + ]; + } +} diff --git a/tests/MockResolver.php b/tests/MockResolver.php new file mode 100644 index 0000000..0099987 --- /dev/null +++ b/tests/MockResolver.php @@ -0,0 +1,143 @@ +}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + public function setEndpoint(string $endpoint): self + { + $this->endpoint = $endpoint; + $this->exception = null; + + return $this; + } + + public function setException(\Exception $exception): self + { + $this->exception = $exception; + $this->endpoint = null; + + return $this; + } + + public function resolve(string $resourceId): Result + { + if ($this->exception !== null) { + throw $this->exception; + } + + if ($this->endpoint === null) { + throw new Exception('No endpoint configured', Exception::NOT_FOUND); + } + + return new Result( + endpoint: $this->endpoint, + metadata: ['resourceId' => $resourceId] + ); + } + + /** + * @param array $metadata + */ + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + /** + * @param array $metadata + */ + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + /** + * @param array $metadata + */ + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'mock', + 'connects' => count($this->connects), + 'disconnects' => count($this->disconnects), + 'activities' => count($this->activities), + ]; + } + + /** + * @return array}> + */ + public function getConnects(): array + { + return $this->connects; + } + + /** + * @return array}> + */ + public function getDisconnects(): array + { + return $this->disconnects; + } + + /** + * @return array}> + */ + public function getActivities(): array + { + return $this->activities; + } + + /** + * @return array + */ + public function getInvalidations(): array + { + return $this->invalidations; + } + + public function reset(): void + { + $this->connects = []; + $this->disconnects = []; + $this->activities = []; + $this->invalidations = []; + } +} diff --git a/tests/OnResolveCallbackTest.php b/tests/OnResolveCallbackTest.php new file mode 100644 index 0000000..caf457c --- /dev/null +++ b/tests/OnResolveCallbackTest.php @@ -0,0 +1,223 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + /** + * Test that onResolve() sets the callback and returns the adapter for chaining + */ + public function testOnResolveSetsCallback(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $result = $adapter->onResolve(function (string $resourceId) { + return '1.2.3.4:8080'; + }); + + $this->assertSame($adapter, $result); + } + + /** + * Test that route() uses the callback when set, bypassing the resolver + */ + public function testRouteUsesCallbackWhenSet(): void + { + $this->resolver->setEndpoint('should-not-be-used.example.com:8080'); + + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): string { + return 'callback-host.example.com:9090'; + }); + + $result = $adapter->route('test-resource'); + + $this->assertInstanceOf(ConnectionResult::class, $result); + $this->assertSame('callback-host.example.com:9090', $result->endpoint); + } + + /** + * Test that route() falls back to resolver when callback is null + */ + public function testRouteFallsBackToResolverWhenCallbackIsNull(): void + { + $this->resolver->setEndpoint('resolver-host.example.com:8080'); + + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $result = $adapter->route('test-resource'); + + $this->assertSame('resolver-host.example.com:8080', $result->endpoint); + } + + /** + * Test that callback can return a string endpoint + */ + public function testCallbackReturnsStringEndpoint(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): string { + return 'string-endpoint.example.com:5432'; + }); + + $result = $adapter->route('my-db'); + + $this->assertSame('string-endpoint.example.com:5432', $result->endpoint); + $this->assertFalse($result->metadata['cached']); + } + + /** + * Test that callback can return a Result object + */ + public function testCallbackReturnsResultObject(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): ResolverResult { + return new ResolverResult( + endpoint: 'result-endpoint.example.com:3306', + metadata: ['custom' => 'metadata', 'resourceId' => $resourceId], + ); + }); + + $result = $adapter->route('my-db'); + + $this->assertSame('result-endpoint.example.com:3306', $result->endpoint); + $this->assertSame('metadata', $result->metadata['custom']); + $this->assertSame('my-db', $result->metadata['resourceId']); + $this->assertFalse($result->metadata['cached']); + } + + /** + * Test that callback receives the correct resource ID + */ + public function testCallbackReceivesResourceId(): void + { + $receivedIds = []; + + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId) use (&$receivedIds): string { + $receivedIds[] = $resourceId; + return 'host.example.com:8080'; + }); + + $adapter->route('resource-alpha'); + // Wait for cache to expire + $start = time(); + while (time() === $start) { + usleep(1000); + } + $adapter->route('resource-beta'); + + $this->assertContains('resource-alpha', $receivedIds); + $this->assertContains('resource-beta', $receivedIds); + } + + /** + * Test that route() throws when neither callback nor resolver is set + */ + public function testRouteThrowsWhenNoCallbackOrResolver(): void + { + $adapter = new Adapter(null, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('No resolver or resolve callback configured'); + + $adapter->route('test-resource'); + } + + /** + * Test that callback takes priority over resolver + */ + public function testCallbackTakesPriorityOverResolver(): void + { + $resolverCalled = false; + + $mockResolver = new class () extends MockResolver { + public bool $wasCalled = false; + + public function __construct() + { + parent::setEndpoint('resolver.example.com:8080'); + } + + public function resolve(string $resourceId): \Utopia\Proxy\Resolver\Result + { + $this->wasCalled = true; + return parent::resolve($resourceId); + } + }; + + $adapter = new Adapter($mockResolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): string { + return 'callback.example.com:8080'; + }); + + $result = $adapter->route('test-resource'); + + $this->assertSame('callback.example.com:8080', $result->endpoint); + $this->assertFalse($mockResolver->wasCalled); + } + + /** + * Test that result from callback with string gets wrapped in default metadata + */ + public function testStringCallbackResultHasDefaultMetadata(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): string { + return 'host.example.com:8080'; + }); + + $result = $adapter->route('test-resource'); + + $this->assertArrayHasKey('cached', $result->metadata); + $this->assertFalse($result->metadata['cached']); + } + + /** + * Test that Result metadata from callback is merged into connection result + */ + public function testResultObjectMetadataIsMerged(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->onResolve(function (string $resourceId): ResolverResult { + return new ResolverResult( + endpoint: 'host.example.com:8080', + metadata: ['region' => 'us-east-1', 'tier' => 'premium'], + ); + }); + + $result = $adapter->route('test-resource'); + + $this->assertSame('us-east-1', $result->metadata['region']); + $this->assertSame('premium', $result->metadata['tier']); + $this->assertFalse($result->metadata['cached']); + } +} diff --git a/tests/Performance/PerformanceTest.php b/tests/Performance/PerformanceTest.php new file mode 100644 index 0000000..ffd1820 --- /dev/null +++ b/tests/Performance/PerformanceTest.php @@ -0,0 +1,751 @@ + + */ + private static array $results = []; + + public static function tearDownAfterClass(): void + { + if (empty(self::$results)) { + return; + } + + echo "\n"; + echo "=================================================================\n"; + echo " PERFORMANCE BENCHMARK RESULTS\n"; + echo "=================================================================\n"; + echo sprintf("%-35s %15s %10s %10s\n", 'Metric', 'Value', 'Target', 'Status'); + echo "-----------------------------------------------------------------\n"; + + foreach (self::$results as $name => $result) { + $targetStr = $result['target'] !== null + ? sprintf('%.2f', $result['target']) + : 'N/A'; + + $statusStr = match ($result['passed']) { + true => 'PASS', + false => 'FAIL', + null => '-', + }; + + echo sprintf( + "%-35s %12.2f %s %10s %10s\n", + $name, + $result['value'], + $result['unit'], + $targetStr, + $statusStr, + ); + } + + echo "=================================================================\n\n"; + } + + protected function setUp(): void + { + if (empty(getenv('PERF_TEST_ENABLED'))) { + $this->markTestSkipped('Performance tests disabled. Set PERF_TEST_ENABLED=1 to run.'); + } + + $this->host = getenv('PERF_PROXY_HOST') ?: '127.0.0.1'; + $this->port = (int) (getenv('PERF_PROXY_PORT') ?: 5432); + $this->iterations = (int) (getenv('PERF_ITERATIONS') ?: 1000); + $this->warmupIterations = (int) (getenv('PERF_WARMUP_ITERATIONS') ?: 100); + $this->resourceId = getenv('PERF_DATABASE_ID') ?: 'test-db'; + $this->targetConnRate = (int) (getenv('PERF_TARGET_CONN_RATE') ?: 10000); + $this->maxConnections = (int) (getenv('PERF_MAX_CONNECTIONS') ?: 10000); + } + + /** + * Measure how many TCP connections per second can be established + * and complete the PostgreSQL startup handshake through the proxy. + */ + public function testConnectionRate(): void + { + self::log("Measuring connection rate (target: >{$this->targetConnRate}/sec)"); + + // Warmup + for ($i = 0; $i < $this->warmupIterations; $i++) { + $sock = $this->connectAndStartup(); + if ($sock !== false) { + fclose($sock); + } + } + + // Benchmark + $successful = 0; + $failed = 0; + $start = hrtime(true); + + for ($i = 0; $i < $this->iterations; $i++) { + $sock = $this->connectAndStartup(); + if ($sock !== false) { + $successful++; + fclose($sock); + } else { + $failed++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; // seconds + $rate = $successful / $elapsed; + + self::log(sprintf( + "Connection rate: %.0f/sec (%d successful, %d failed in %.3fs)", + $rate, + $successful, + $failed, + $elapsed, + )); + + $this->recordResult('connection_rate', $rate, '/sec', $this->targetConnRate); + + $this->assertGreaterThan(0, $successful, 'Should establish at least one connection'); + $this->assertGreaterThan( + $this->targetConnRate, + $rate, + sprintf('Connection rate %.0f/sec is below target %d/sec', $rate, $this->targetConnRate), + ); + } + + /** + * Measure queries per second through the proxy by sending PostgreSQL + * simple query protocol messages and counting responses. + */ + public function testQueryThroughput(): void + { + self::log("Measuring query throughput over {$this->iterations} queries"); + + $sock = $this->connectAndStartup(); + $this->assertNotFalse($sock, 'Failed to establish connection for throughput test'); + + // Read and discard the startup response + $this->readResponse($sock, 1.0); + + // Warmup + for ($i = 0; $i < $this->warmupIterations; $i++) { + $this->sendSimpleQuery($sock, 'SELECT 1'); + $this->readResponse($sock, 1.0); + } + + // Benchmark + $successful = 0; + $start = hrtime(true); + + for ($i = 0; $i < $this->iterations; $i++) { + $this->sendSimpleQuery($sock, 'SELECT 1'); + $response = $this->readResponse($sock, 1.0); + if ($response !== false && strlen($response) > 0) { + $successful++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; + $qps = $successful / $elapsed; + $avgLatencyUs = ($elapsed / $successful) * 1e6; + + fclose($sock); + + self::log(sprintf( + "Query throughput: %.0f QPS (%.1f us avg latency, %d/%d successful in %.3fs)", + $qps, + $avgLatencyUs, + $successful, + $this->iterations, + $elapsed, + )); + + $this->recordResult('query_throughput', $qps, 'QPS', null); + $this->recordResult('query_avg_latency', $avgLatencyUs, 'us', null); + + $this->assertGreaterThan(0, $successful, 'Should complete at least one query'); + } + + /** + * Measure time from first connection to first query response. This includes + * the resolver lookup, backend connection establishment, and initial handshake. + */ + public function testColdStartLatency(): void + { + self::log("Measuring cold start latency"); + + // Run multiple cold starts and compute percentiles + $latencies = []; + $attempts = min($this->iterations, 50); // Cold starts are expensive + + for ($i = 0; $i < $attempts; $i++) { + $start = hrtime(true); + + $sock = $this->connectAndStartup(); + if ($sock === false) { + continue; + } + + // Read startup response + $startupResponse = $this->readResponse($sock, 5.0); + + // Send first query + $this->sendSimpleQuery($sock, 'SELECT 1'); + $queryResponse = $this->readResponse($sock, 5.0); + + $elapsed = (hrtime(true) - $start) / 1e6; // milliseconds + + if ($queryResponse !== false) { + $latencies[] = $elapsed; + } + + fclose($sock); + } + + $this->assertNotEmpty($latencies, 'Should complete at least one cold start'); + + sort($latencies); + $count = count($latencies); + $p50 = $latencies[(int) ($count * 0.5)]; + $p95 = $latencies[(int) ($count * 0.95)]; + $p99 = $latencies[min((int) ($count * 0.99), $count - 1)]; + $avg = array_sum($latencies) / $count; + + self::log(sprintf( + "Cold start latency: avg=%.2fms p50=%.2fms p95=%.2fms p99=%.2fms (%d samples)", + $avg, + $p50, + $p95, + $p99, + $count, + )); + + $this->recordResult('cold_start_avg', $avg, 'ms', null); + $this->recordResult('cold_start_p50', $p50, 'ms', null); + $this->recordResult('cold_start_p95', $p95, 'ms', null); + $this->recordResult('cold_start_p99', $p99, 'ms', null); + } + + /** + * Measure the time to detect backend failure and establish a new connection. + * This simulates what happens when the resolver returns a different backend + * after the current one goes down. + * + * Note: This test measures the client-side reconnection overhead, not the + * resolver failover itself (which depends on external state). + */ + public function testFailoverLatency(): void + { + self::log("Measuring failover/reconnection latency"); + + $latencies = []; + $attempts = min($this->iterations, 100); + + for ($i = 0; $i < $attempts; $i++) { + // Establish initial connection + $sock = $this->connectAndStartup(); + if ($sock === false) { + continue; + } + + $this->readResponse($sock, 1.0); + + // Close the connection (simulates backend going away) + fclose($sock); + + // Measure reconnection time + $start = hrtime(true); + + $newSock = $this->connectAndStartup(); + if ($newSock === false) { + continue; + } + + $reconnectResponse = $this->readResponse($newSock, 5.0); + $elapsed = (hrtime(true) - $start) / 1e6; // milliseconds + + if ($reconnectResponse !== false) { + $latencies[] = $elapsed; + } + + fclose($newSock); + } + + $this->assertNotEmpty($latencies, 'Should complete at least one reconnection'); + + sort($latencies); + $count = count($latencies); + $p50 = $latencies[(int) ($count * 0.5)]; + $p95 = $latencies[(int) ($count * 0.95)]; + $avg = array_sum($latencies) / $count; + + self::log(sprintf( + "Failover latency: avg=%.2fms p50=%.2fms p95=%.2fms (%d samples)", + $avg, + $p50, + $p95, + $count, + )); + + $this->recordResult('failover_avg', $avg, 'ms', null); + $this->recordResult('failover_p50', $p50, 'ms', null); + $this->recordResult('failover_p95', $p95, 'ms', null); + } + + /** + * Send increasingly large payloads (1KB, 10KB, 100KB, 1MB, 10MB) through + * the proxy and measure throughput at each size. + */ + public function testLargePayloadThroughput(): void + { + $sizes = [ + '1KB' => 1024, + '10KB' => 10 * 1024, + '100KB' => 100 * 1024, + '1MB' => 1024 * 1024, + '10MB' => 10 * 1024 * 1024, + ]; + + foreach ($sizes as $label => $size) { + self::log("Testing payload throughput at {$label}"); + + $sock = $this->connectAndStartup(); + if ($sock === false) { + self::log(" Skipping {$label}: connection failed"); + continue; + } + + // Read startup response + $this->readResponse($sock, 2.0); + + // Build a query payload of the target size + // Use a PostgreSQL simple query with a large string literal + $padding = str_repeat('X', max(0, $size - 64)); + $query = "SELECT '{$padding}'"; + + $iterationsForSize = match (true) { + $size <= 1024 => 500, + $size <= 10240 => 200, + $size <= 102400 => 50, + $size <= 1048576 => 10, + default => 3, + }; + + $totalBytes = 0; + $successful = 0; + $start = hrtime(true); + + for ($i = 0; $i < $iterationsForSize; $i++) { + $this->sendSimpleQuery($sock, $query); + $response = $this->readResponse($sock, 10.0); + if ($response !== false) { + $totalBytes += strlen($query) + strlen($response); + $successful++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; + $throughputMBps = ($totalBytes / (1024 * 1024)) / $elapsed; + + fclose($sock); + + self::log(sprintf( + " %s: %.2f MB/s throughput (%d/%d successful, %.3fs elapsed)", + $label, + $throughputMBps, + $successful, + $iterationsForSize, + $elapsed, + )); + + $this->recordResult("payload_{$label}_throughput", $throughputMBps, 'MB/s', null); + + $this->assertGreaterThan(0, $successful, "Should complete at least one {$label} transfer"); + } + } + + /** + * Open connections until the max_connections limit is reached. + * Verify the proxy handles this gracefully (rejects with an error + * rather than crashing or hanging). + */ + public function testConnectionPoolExhaustion(): void + { + $targetConnections = min($this->maxConnections, 5000); // Cap for safety + self::log("Testing connection exhaustion up to {$targetConnections} connections"); + + /** @var resource[] $sockets */ + $sockets = []; + $peakConnections = 0; + $firstRefusalAt = null; + + for ($i = 0; $i < $targetConnections; $i++) { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 0.5, + ); + + if ($sock === false) { + $firstRefusalAt = $i; + self::log(" Connection refused at connection #{$i}: [{$errno}] {$errstr}"); + break; + } + + stream_set_timeout($sock, 0, 100000); // 100ms timeout + $sockets[] = $sock; + $peakConnections = $i + 1; + + // Log progress every 1000 connections + if ($peakConnections % 1000 === 0) { + self::log(" Opened {$peakConnections} connections..."); + } + } + + self::log(sprintf( + "Peak connections: %d (refusal at: %s)", + $peakConnections, + $firstRefusalAt !== null ? "#{$firstRefusalAt}" : 'none', + )); + + $this->recordResult('peak_connections', (float) $peakConnections, 'conn', null); + + // Verify we can still connect after closing some connections + $closedCount = min(100, count($sockets)); + for ($i = 0; $i < $closedCount; $i++) { + $sock = array_pop($sockets); + if ($sock !== null) { + fclose($sock); + } + } + + // Small delay for the proxy to process disconnections + usleep(100000); // 100ms + + $recoverySock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 2.0, + ); + + if ($recoverySock !== false) { + self::log(" Recovery connection successful after releasing {$closedCount} connections"); + fclose($recoverySock); + } else { + self::log(" Recovery connection failed: [{$errno}] {$errstr}"); + } + + // Clean up remaining sockets + foreach ($sockets as $sock) { + fclose($sock); + } + + $this->assertGreaterThan(0, $peakConnections, 'Should open at least one connection'); + + // If we hit refusal, verify it was at a reasonable point + if ($firstRefusalAt !== null) { + $this->assertGreaterThan( + 10, + $firstRefusalAt, + 'Proxy should handle at least 10 connections before refusing', + ); + } + } + + /** + * Measure query latency with 10, 100, 1000, and 10000 concurrent connections + * to observe how the proxy scales under increasing load. + */ + public function testConcurrentConnectionScaling(): void + { + $concurrencyLevels = [10, 100, 1000, 10000]; + + foreach ($concurrencyLevels as $level) { + if ($level > $this->maxConnections) { + self::log("Skipping concurrency level {$level} (exceeds max {$this->maxConnections})"); + continue; + } + + self::log("Testing with {$level} concurrent connections"); + + // Establish connections + /** @var resource[] $sockets */ + $sockets = []; + $established = 0; + + for ($i = 0; $i < $level; $i++) { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 1.0, + ); + + if ($sock === false) { + break; + } + + stream_set_timeout($sock, 1); + stream_set_blocking($sock, false); + $sockets[] = $sock; + $established++; + } + + if ($established < $level) { + self::log(" Only established {$established}/{$level} connections"); + } + + if ($established === 0) { + self::log(" No connections established, skipping"); + $this->recordResult("latency_at_{$level}", 0, 'ms', null); + continue; + } + + // Send startup on all connections + foreach ($sockets as $sock) { + stream_set_blocking($sock, true); + stream_set_timeout($sock, 1); + $startupMsg = $this->buildStartupMessage($this->resourceId); + @fwrite($sock, $startupMsg); + } + + // Small settle time + usleep(50000); + + // Measure round-trip latency on a sample of connections + $sampleSize = min(100, $established); + $sampleSockets = array_slice($sockets, 0, $sampleSize); + $latencies = []; + + foreach ($sampleSockets as $sock) { + stream_set_blocking($sock, true); + stream_set_timeout($sock, 1); + + // Drain any pending data + $this->readResponse($sock, 0.1); + + $start = hrtime(true); + $this->sendSimpleQuery($sock, 'SELECT 1'); + $response = $this->readResponse($sock, 2.0); + $elapsed = (hrtime(true) - $start) / 1e6; + + if ($response !== false && strlen($response) > 0) { + $latencies[] = $elapsed; + } + } + + // Clean up + foreach ($sockets as $sock) { + @fclose($sock); + } + + if (!empty($latencies)) { + sort($latencies); + $count = count($latencies); + $avg = array_sum($latencies) / $count; + $p50 = $latencies[(int) ($count * 0.5)]; + $p99 = $latencies[min((int) ($count * 0.99), $count - 1)]; + + self::log(sprintf( + " %d conns: avg=%.2fms p50=%.2fms p99=%.2fms (%d samples)", + $level, + $avg, + $p50, + $p99, + $count, + )); + + $this->recordResult("latency_at_{$level}_avg", $avg, 'ms', null); + $this->recordResult("latency_at_{$level}_p99", $p99, 'ms', null); + } else { + self::log(" No successful queries at {$level} concurrency"); + $this->recordResult("latency_at_{$level}_avg", 0, 'ms', null); + } + } + + // At minimum, the lowest concurrency level should work + $this->assertArrayHasKey('latency_at_10_avg', self::$results); + } + + /** + * Build a PostgreSQL StartupMessage with the database name encoding the + * database ID for the proxy resolver. + * + * Wire format: + * Int32 length (includes self) + * Int32 protocol version (3.0 = 196608) + * String "user" \0 String \0 + * String "database" \0 String "db-" \0 + * \0 (terminator) + */ + private function buildStartupMessage(string $resourceId): string + { + $params = "user\x00appwrite\x00database\x00db-{$resourceId}\x00\x00"; + $protocolVersion = pack('N', 196608); // 3.0 + $length = 4 + strlen($protocolVersion) + strlen($params); + + return pack('N', $length) . $protocolVersion . $params; + } + + /** + * Build a PostgreSQL Simple Query message. + * + * Wire format: + * Byte1 'Q' + * Int32 length (includes self but not message type) + * String query \0 + */ + private function buildSimpleQueryMessage(string $query): string + { + $queryWithNull = $query . "\x00"; + $length = 4 + strlen($queryWithNull); + + return 'Q' . pack('N', $length) . $queryWithNull; + } + + /** + * Connect to the proxy and send a PostgreSQL startup message. + * + * @return resource|false Socket resource on success, false on failure + */ + private function connectAndStartup(): mixed + { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 2.0, + ); + + if ($sock === false) { + return false; + } + + stream_set_timeout($sock, 5); + + $startupMsg = $this->buildStartupMessage($this->resourceId); + $written = @fwrite($sock, $startupMsg); + + if ($written === false || $written === 0) { + fclose($sock); + return false; + } + + return $sock; + } + + /** + * Send a PostgreSQL simple query on an established connection. + * + * @param resource $sock + */ + private function sendSimpleQuery($sock, string $query): bool + { + $msg = $this->buildSimpleQueryMessage($query); + $written = @fwrite($sock, $msg); + + return $written !== false && $written > 0; + } + + /** + * Read a response from the proxy with a timeout. + * + * @param resource $sock + * @return string|false Response data or false on failure/timeout + */ + private function readResponse($sock, float $timeoutSeconds): string|false + { + $timeoutSec = (int) $timeoutSeconds; + $timeoutUsec = (int) (($timeoutSeconds - $timeoutSec) * 1e6); + stream_set_timeout($sock, $timeoutSec, $timeoutUsec); + + $data = @fread($sock, 65536); + + if ($data === false || $data === '') { + $meta = stream_get_meta_data($sock); + if ($meta['timed_out']) { + return false; + } + return false; + } + + return $data; + } + + /** + * Record a benchmark result for the summary table. + */ + private function recordResult(string $name, float $value, string $unit, ?float $target): void + { + $passed = null; + if ($target !== null) { + // For rates/throughput, higher is better + if (str_contains($unit, '/sec') || str_contains($unit, 'QPS') || str_contains($unit, 'MB/s')) { + $passed = $value >= $target; + } else { + // For latency, lower is better + $passed = $value <= $target; + } + } + + self::$results[$name] = [ + 'metric' => $name, + 'value' => $value, + 'unit' => $unit, + 'target' => $target, + 'passed' => $passed, + ]; + } + + /** + * Log a message with timestamp. + */ + private static function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + echo "[{$timestamp}] [PERF] {$message}\n"; + } +} diff --git a/tests/ProtocolTest.php b/tests/ProtocolTest.php new file mode 100644 index 0000000..1c245f7 --- /dev/null +++ b/tests/ProtocolTest.php @@ -0,0 +1,103 @@ +assertSame('http', Protocol::HTTP->value); + $this->assertSame('smtp', Protocol::SMTP->value); + $this->assertSame('tcp', Protocol::TCP->value); + $this->assertSame('postgresql', Protocol::PostgreSQL->value); + $this->assertSame('mysql', Protocol::MySQL->value); + $this->assertSame('mongodb', Protocol::MongoDB->value); + $this->assertSame('redis', Protocol::Redis->value); + $this->assertSame('memcached', Protocol::Memcached->value); + $this->assertSame('kafka', Protocol::Kafka->value); + $this->assertSame('amqp', Protocol::AMQP->value); + $this->assertSame('clickhouse', Protocol::ClickHouse->value); + $this->assertSame('cassandra', Protocol::Cassandra->value); + $this->assertSame('nats', Protocol::NATS->value); + $this->assertSame('mssql', Protocol::MSSQL->value); + $this->assertSame('oracle', Protocol::Oracle->value); + $this->assertSame('elasticsearch', Protocol::Elasticsearch->value); + $this->assertSame('mqtt', Protocol::MQTT->value); + $this->assertSame('grpc', Protocol::GRPC->value); + $this->assertSame('zookeeper', Protocol::ZooKeeper->value); + $this->assertSame('etcd', Protocol::Etcd->value); + $this->assertSame('neo4j', Protocol::Neo4j->value); + $this->assertSame('couchbase', Protocol::Couchbase->value); + $this->assertSame('cockroachdb', Protocol::CockroachDB->value); + $this->assertSame('tidb', Protocol::TiDB->value); + $this->assertSame('pulsar', Protocol::Pulsar->value); + $this->assertSame('ftp', Protocol::FTP->value); + $this->assertSame('ldap', Protocol::LDAP->value); + $this->assertSame('rethinkdb', Protocol::RethinkDB->value); + } + + public function testProtocolCount(): void + { + $cases = Protocol::cases(); + $this->assertCount(28, $cases); + } + + public function testProtocolFromValue(): void + { + $this->assertSame(Protocol::HTTP, Protocol::from('http')); + $this->assertSame(Protocol::SMTP, Protocol::from('smtp')); + $this->assertSame(Protocol::TCP, Protocol::from('tcp')); + $this->assertSame(Protocol::PostgreSQL, Protocol::from('postgresql')); + $this->assertSame(Protocol::MySQL, Protocol::from('mysql')); + $this->assertSame(Protocol::MongoDB, Protocol::from('mongodb')); + $this->assertSame(Protocol::Redis, Protocol::from('redis')); + $this->assertSame(Protocol::Memcached, Protocol::from('memcached')); + $this->assertSame(Protocol::Kafka, Protocol::from('kafka')); + $this->assertSame(Protocol::AMQP, Protocol::from('amqp')); + $this->assertSame(Protocol::ClickHouse, Protocol::from('clickhouse')); + $this->assertSame(Protocol::Cassandra, Protocol::from('cassandra')); + $this->assertSame(Protocol::NATS, Protocol::from('nats')); + $this->assertSame(Protocol::MSSQL, Protocol::from('mssql')); + $this->assertSame(Protocol::Oracle, Protocol::from('oracle')); + $this->assertSame(Protocol::Elasticsearch, Protocol::from('elasticsearch')); + $this->assertSame(Protocol::MQTT, Protocol::from('mqtt')); + $this->assertSame(Protocol::GRPC, Protocol::from('grpc')); + $this->assertSame(Protocol::ZooKeeper, Protocol::from('zookeeper')); + $this->assertSame(Protocol::Etcd, Protocol::from('etcd')); + $this->assertSame(Protocol::Neo4j, Protocol::from('neo4j')); + $this->assertSame(Protocol::Couchbase, Protocol::from('couchbase')); + $this->assertSame(Protocol::CockroachDB, Protocol::from('cockroachdb')); + $this->assertSame(Protocol::TiDB, Protocol::from('tidb')); + $this->assertSame(Protocol::Pulsar, Protocol::from('pulsar')); + $this->assertSame(Protocol::FTP, Protocol::from('ftp')); + $this->assertSame(Protocol::LDAP, Protocol::from('ldap')); + $this->assertSame(Protocol::RethinkDB, Protocol::from('rethinkdb')); + } + + public function testProtocolTryFromInvalidReturnsNull(): void + { + $invalid = Protocol::tryFrom('invalid'); + $empty = Protocol::tryFrom(''); + $uppercase = Protocol::tryFrom('HTTP'); // case-sensitive + + $this->assertSame(null, $invalid); + $this->assertSame(null, $empty); + $this->assertSame(null, $uppercase); + } + + public function testProtocolFromInvalidThrows(): void + { + $this->expectException(\ValueError::class); + Protocol::from('invalid'); + } + + public function testProtocolIsBackedEnum(): void + { + $reflection = new \ReflectionEnum(Protocol::class); + $this->assertTrue($reflection->isBacked()); + $this->assertSame('string', $reflection->getBackingType()->getName()); + } +} diff --git a/tests/ResolverExtendedTest.php b/tests/ResolverExtendedTest.php new file mode 100644 index 0000000..1d5a4b8 --- /dev/null +++ b/tests/ResolverExtendedTest.php @@ -0,0 +1,209 @@ +assertTrue($reflection->isReadOnly()); + } + + public function testResultWithEmptyEndpoint(): void + { + $result = new ResolverResult(endpoint: ''); + $this->assertSame('', $result->endpoint); + } + + public function testResultWithLargeMetadata(): void + { + $metadata = []; + for ($i = 0; $i < 100; $i++) { + $metadata["key_{$i}"] = "value_{$i}"; + } + + $result = new ResolverResult(endpoint: 'host:80', metadata: $metadata); + $this->assertCount(100, $result->metadata); + $this->assertSame('value_50', $result->metadata['key_50']); + } + + public function testResultWithZeroTimeout(): void + { + $result = new ResolverResult(endpoint: 'host:80', timeout: 0); + $this->assertSame(0, $result->timeout); + } + + public function testResultWithNegativeTimeout(): void + { + $result = new ResolverResult(endpoint: 'host:80', timeout: -1); + $this->assertSame(-1, $result->timeout); + } + + public function testExceptionNotFound(): void + { + $e = new ResolverException('Not found', ResolverException::NOT_FOUND); + $this->assertSame(404, $e->getCode()); + } + + public function testExceptionUnavailable(): void + { + $e = new ResolverException('Down', ResolverException::UNAVAILABLE); + $this->assertSame(503, $e->getCode()); + } + + public function testExceptionTimeout(): void + { + $e = new ResolverException('Slow', ResolverException::TIMEOUT); + $this->assertSame(504, $e->getCode()); + } + + public function testExceptionForbidden(): void + { + $e = new ResolverException('Denied', ResolverException::FORBIDDEN); + $this->assertSame(403, $e->getCode()); + } + + public function testExceptionInternal(): void + { + $e = new ResolverException('Crash', ResolverException::INTERNAL); + $this->assertSame(500, $e->getCode()); + } + + public function testExceptionIsInstanceOfBaseException(): void + { + $e = new ResolverException('test'); + $this->assertInstanceOf(\Exception::class, $e); + } + + public function testExceptionContextIsReadonly(): void + { + $e = new ResolverException('test', context: ['key' => 'value']); + + $reflection = new \ReflectionProperty($e, 'context'); + $this->assertTrue($reflection->isReadOnly()); + } + + public function testExceptionWithEmptyContext(): void + { + $e = new ResolverException('test'); + $this->assertSame([], $e->context); + } + + public function testExceptionWithRichContext(): void + { + $context = [ + 'resourceId' => 'db-123', + 'attempt' => 3, + 'lastError' => 'connection refused', + 'timestamps' => [1000, 2000, 3000], + ]; + + $e = new ResolverException('Failed after retries', ResolverException::UNAVAILABLE, $context); + + $this->assertSame('db-123', $e->context['resourceId']); + $this->assertSame(3, $e->context['attempt']); + $this->assertSame([1000, 2000, 3000], $e->context['timestamps']); + } + + public function testMockResolverResolvesEndpoint(): void + { + $resolver = new MockResolver(); + $resolver->setEndpoint('backend.db:5432'); + + $result = $resolver->resolve('test-resource'); + + $this->assertSame('backend.db:5432', $result->endpoint); + $this->assertSame('test-resource', $result->metadata['resourceId']); + } + + public function testMockResolverThrowsWhenNoEndpoint(): void + { + $resolver = new MockResolver(); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(404); + + $resolver->resolve('test-resource'); + } + + public function testMockResolverThrowsConfiguredException(): void + { + $resolver = new MockResolver(); + $resolver->setException(new ResolverException('custom error', ResolverException::TIMEOUT)); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('custom error'); + $this->expectExceptionCode(504); + + $resolver->resolve('test-resource'); + } + + public function testMockResolverTracksActivities(): void + { + $resolver = new MockResolver(); + + $resolver->track('resource-1', ['type' => 'query']); + $resolver->track('resource-2', ['type' => 'heartbeat']); + + $activities = $resolver->getActivities(); + $this->assertCount(2, $activities); + $this->assertSame('resource-1', $activities[0]['resourceId']); + $this->assertSame('query', $activities[0]['metadata']['type']); + } + + public function testMockResolverTracksPurges(): void + { + $resolver = new MockResolver(); + + $resolver->purge('resource-1'); + $resolver->purge('resource-2'); + + $invalidations = $resolver->getInvalidations(); + $this->assertCount(2, $invalidations); + $this->assertSame('resource-1', $invalidations[0]); + $this->assertSame('resource-2', $invalidations[1]); + } + + public function testMockResolverResetClearsEverything(): void + { + $resolver = new MockResolver(); + + $resolver->setEndpoint('host:80'); + $resolver->resolve('test'); + $resolver->track('test'); + $resolver->purge('test'); + $resolver->onConnect('test'); + $resolver->onDisconnect('test'); + + $resolver->reset(); + + $this->assertEmpty($resolver->getConnects()); + $this->assertEmpty($resolver->getDisconnects()); + $this->assertEmpty($resolver->getActivities()); + $this->assertEmpty($resolver->getInvalidations()); + } + + public function testMockResolverStats(): void + { + $resolver = new MockResolver(); + + $resolver->onConnect('r1'); + $resolver->onConnect('r2'); + $resolver->onDisconnect('r1'); + $resolver->track('r2'); + + $stats = $resolver->getStats(); + $this->assertSame('mock', $stats['resolver']); + $this->assertSame(2, $stats['connects']); + $this->assertSame(1, $stats['disconnects']); + $this->assertSame(1, $stats['activities']); + } + +} diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php new file mode 100644 index 0000000..6429f1a --- /dev/null +++ b/tests/ResolverTest.php @@ -0,0 +1,62 @@ + false, 'type' => 'http'], + timeout: 30 + ); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(['cached' => false, 'type' => 'http'], $result->metadata); + $this->assertSame(30, $result->timeout); + } + + public function testResolverResultDefaultValues(): void + { + $result = new ResolverResult(endpoint: '127.0.0.1:8080'); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame([], $result->metadata); + $this->assertNull($result->timeout); + } + + public function testResolverExceptionWithContext(): void + { + $exception = new ResolverException( + 'Resource not found', + ResolverException::NOT_FOUND, + ['resourceId' => 'abc123', 'type' => 'database'] + ); + + $this->assertSame('Resource not found', $exception->getMessage()); + $this->assertSame(404, $exception->getCode()); + $this->assertSame(['resourceId' => 'abc123', 'type' => 'database'], $exception->context); + } + + public function testResolverExceptionErrorCodes(): void + { + $this->assertSame(404, ResolverException::NOT_FOUND); + $this->assertSame(503, ResolverException::UNAVAILABLE); + $this->assertSame(504, ResolverException::TIMEOUT); + $this->assertSame(403, ResolverException::FORBIDDEN); + $this->assertSame(500, ResolverException::INTERNAL); + } + + public function testResolverExceptionDefaultCode(): void + { + $exception = new ResolverException('Internal error'); + + $this->assertSame(500, $exception->getCode()); + $this->assertSame([], $exception->context); + } +} diff --git a/tests/RoutingCacheTest.php b/tests/RoutingCacheTest.php new file mode 100644 index 0000000..f7b549d --- /dev/null +++ b/tests/RoutingCacheTest.php @@ -0,0 +1,213 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testFirstCallIsCacheMiss(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + // Ensure we're at the start of a clean second + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $result = $adapter->route('resource-1'); + + $this->assertFalse($result->metadata['cached']); + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + } + + public function testSecondCallWithinOneSecondIsCacheHit(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('resource-1'); + $second = $adapter->route('resource-1'); + + $this->assertFalse($first->metadata['cached']); + $this->assertTrue($second->metadata['cached']); + } + + public function testCacheExpiresAfterTtl(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(1); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + + sleep(1); + + $result = $adapter->route('resource-1'); + $this->assertFalse($result->metadata['cached']); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['cacheMisses']); + } + + public function testMultipleResourcesCachedIndependently(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $adapter->route('resource-2'); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(2, $stats['routingTableSize']); + } + + public function testCacheHitPreservesProtocol(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::SMTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $cached = $adapter->route('resource-1'); + + $this->assertSame(Protocol::SMTP, $cached->protocol); + } + + public function testCacheHitPreservesEndpoint(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $cached = $adapter->route('resource-1'); + + $this->assertSame('8.8.8.8:80', $cached->endpoint); + } + + public function testInitialStatsAreZero(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + + $this->assertSame(0, $stats['connections']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(0, $stats['cacheMisses']); + $this->assertSame(0, $stats['routingErrors']); + $this->assertSame(0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingTableSize']); + } + + public function testStatsContainAdapterInfo(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + + $this->assertSame('Adapter', $stats['adapter']); + $this->assertSame('http', $stats['protocol']); + } + + public function testStatsRoutingTableMemoryIsPositive(): void + { + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + $this->assertGreaterThan(0, $stats['routingTableMemory']); + } + + public function testCacheHitRateCalculation(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + $adapter->setCacheTTL(60); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + // 1 miss, then 3 hits = 75% hit rate + $adapter->route('resource-1'); + $adapter->route('resource-1'); + $adapter->route('resource-1'); + $adapter->route('resource-1'); + + $stats = $adapter->getStats(); + $this->assertSame(75.0, $stats['cacheHitRate']); + } + + public function testMultipleErrorsIncrementStats(): void + { + $this->resolver->setException(new \Utopia\Proxy\Resolver\Exception('fail')); + $adapter = new Adapter($this->resolver, protocol: Protocol::HTTP); + + for ($i = 0; $i < 3; $i++) { + try { + $adapter->route('resource-1'); + } catch (\Exception $e) { + // expected + } + } + + $stats = $adapter->getStats(); + $this->assertSame(3, $stats['routingErrors']); + $this->assertSame(3, $stats['cacheMisses']); + } +} diff --git a/tests/TCPAdapterExtendedTest.php b/tests/TCPAdapterExtendedTest.php new file mode 100644 index 0000000..49d63b8 --- /dev/null +++ b/tests/TCPAdapterExtendedTest.php @@ -0,0 +1,65 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testProtocolForPostgresPort(): void + { + $adapter = new TCPAdapter(port: 5432, resolver: $this->resolver); + $this->assertSame(Protocol::PostgreSQL, $adapter->getProtocol()); + } + + public function testProtocolForMysqlPort(): void + { + $adapter = new TCPAdapter(port: 3306, resolver: $this->resolver); + $this->assertSame(Protocol::MySQL, $adapter->getProtocol()); + } + + public function testProtocolForMongoPort(): void + { + $adapter = new TCPAdapter(port: 27017, resolver: $this->resolver); + $this->assertSame(Protocol::MongoDB, $adapter->getProtocol()); + } + + public function testUnknownPortReturnsTcp(): void + { + $adapter = new TCPAdapter(port: 8080, resolver: $this->resolver); + $this->assertSame(Protocol::TCP, $adapter->getProtocol()); + } + + public function testPortProperty(): void + { + $adapter = new TCPAdapter(port: 5432, resolver: $this->resolver); + $this->assertSame(5432, $adapter->port); + } + + public function testSetTimeoutReturnsSelf(): void + { + $adapter = new TCPAdapter(port: 5432, resolver: $this->resolver); + $result = $adapter->setTimeout(10.0); + $this->assertSame($adapter, $result); + } + + public function testSetConnectTimeoutReturnsSelf(): void + { + $adapter = new TCPAdapter(port: 5432, resolver: $this->resolver); + $result = $adapter->setConnectTimeout(10.0); + $this->assertSame($adapter, $result); + } +} diff --git a/tests/TCPAdapterTest.php b/tests/TCPAdapterTest.php new file mode 100644 index 0000000..c14e45e --- /dev/null +++ b/tests/TCPAdapterTest.php @@ -0,0 +1,39 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testProtocolDetection(): void + { + $postgresql = new TCPAdapter(port: 5432, resolver: $this->resolver); + $this->assertSame(Protocol::PostgreSQL, $postgresql->getProtocol()); + + $mysql = new TCPAdapter(port: 3306, resolver: $this->resolver); + $this->assertSame(Protocol::MySQL, $mysql->getProtocol()); + + $mongodb = new TCPAdapter(port: 27017, resolver: $this->resolver); + $this->assertSame(Protocol::MongoDB, $mongodb->getProtocol()); + } + + public function testPort(): void + { + $adapter = new TCPAdapter(port: 3306, resolver: $this->resolver); + $this->assertSame(3306, $adapter->port); + } +} diff --git a/tests/TLSContextTest.php b/tests/TLSContextTest.php new file mode 100644 index 0000000..abab110 --- /dev/null +++ b/tests/TLSContextTest.php @@ -0,0 +1,182 @@ +markTestSkipped('ext-swoole is required to run TLSContext tests.'); + } + } + + public function testToSwooleConfigBasic(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/server.crt', $config['ssl_cert_file']); + $this->assertSame('/certs/server.key', $config['ssl_key_file']); + $this->assertSame(TLS::DEFAULT_CIPHERS, $config['ssl_ciphers']); + $this->assertSame(SWOOLE_SSL_TLSv1_2 | SWOOLE_SSL_TLSv1_3, $config['ssl_protocols']); + $this->assertFalse($config['ssl_allow_self_signed']); + $this->assertFalse($config['ssl_verify_peer']); + $this->assertArrayNotHasKey('ssl_client_cert_file', $config); + $this->assertArrayNotHasKey('ssl_verify_depth', $config); + } + + public function testToSwooleConfigWithCaPath(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + ); + $ctx = new TLSContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/ca.crt', $config['ssl_client_cert_file']); + $this->assertFalse($config['ssl_verify_peer']); + } + + public function testToSwooleConfigWithMutualTLS(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + requireClientCert: true, + ); + $ctx = new TLSContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/ca.crt', $config['ssl_client_cert_file']); + $this->assertTrue($config['ssl_verify_peer']); + $this->assertSame(10, $config['ssl_verify_depth']); + } + + public function testToSwooleConfigWithCustomCiphers(): void + { + $customCiphers = 'ECDHE-RSA-AES128-GCM-SHA256'; + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ciphers: $customCiphers, + ); + $ctx = new TLSContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame($customCiphers, $config['ssl_ciphers']); + } + + public function testToStreamContextReturnsResource(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $streamCtx = $ctx->toStreamContext(); + + $this->assertSame('stream-context', get_resource_type($streamCtx)); + } + + public function testToStreamContextHasCorrectSslOptions(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + + $this->assertArrayHasKey('ssl', $options); + /** @var array $ssl */ + $ssl = $options['ssl']; + $this->assertSame('/certs/server.crt', $ssl['local_cert']); + $this->assertSame('/certs/server.key', $ssl['local_pk']); + $this->assertTrue($ssl['disable_compression']); + $this->assertFalse($ssl['allow_self_signed']); + $this->assertFalse($ssl['verify_peer']); + $this->assertFalse($ssl['verify_peer_name']); + } + + public function testToStreamContextWithCaFile(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + ); + $ctx = new TLSContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertSame('/certs/ca.crt', $ssl['cafile']); + } + + public function testToStreamContextWithMutualTLS(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + requireClientCert: true, + ); + $ctx = new TLSContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertTrue($ssl['verify_peer']); + $this->assertFalse($ssl['verify_peer_name']); + $this->assertSame(10, $ssl['verify_depth']); + } + + public function testToStreamContextWithoutCaFile(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertArrayNotHasKey('cafile', $ssl); + } + + public function testGetSocketTypeIncludesSslFlag(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $socketType = $ctx->getSocketType(); + + $this->assertSame(SWOOLE_SOCK_TCP | SWOOLE_SSL, $socketType); + } + + public function testGetTlsReturnsOriginalInstance(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + $ctx = new TLSContext($tls); + + $this->assertSame($tls, $ctx->getTls()); + } +} diff --git a/tests/TLSTest.php b/tests/TLSTest.php new file mode 100644 index 0000000..0039bd3 --- /dev/null +++ b/tests/TLSTest.php @@ -0,0 +1,346 @@ +markTestSkipped('ext-swoole is required to run TLS tests.'); + } + } + + public function testConstructorSetsRequiredPaths(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + + $this->assertSame('/certs/server.crt', $tls->certificate); + $this->assertSame('/certs/server.key', $tls->key); + } + + public function testConstructorDefaultValues(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + + $this->assertSame('', $tls->ca); + $this->assertFalse($tls->requireClientCert); + $this->assertSame(TLS::DEFAULT_CIPHERS, $tls->ciphers); + $this->assertSame(TLS::MIN_TLS_VERSION, $tls->minProtocol); + } + + public function testConstructorCustomValues(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + requireClientCert: true, + ciphers: 'ECDHE-RSA-AES128-GCM-SHA256', + minProtocol: SWOOLE_SSL_TLSv1_3, + ); + + $this->assertSame('/certs/ca.crt', $tls->ca); + $this->assertTrue($tls->requireClientCert); + $this->assertSame('ECDHE-RSA-AES128-GCM-SHA256', $tls->ciphers); + $this->assertSame(SWOOLE_SSL_TLSv1_3, $tls->minProtocol); + } + + public function testPgSslRequestConstant(): void + { + $this->assertSame(8, strlen(TLS::PG_SSL_REQUEST)); + // Verify SSL request code bytes: 0x04D2162F = 80877103 + $this->assertSame("\x00\x00\x00\x08\x04\xd2\x16\x2f", TLS::PG_SSL_REQUEST); + } + + public function testPgSslResponseConstants(): void + { + $this->assertSame('S', TLS::PG_SSL_RESPONSE_OK); + $this->assertSame('N', TLS::PG_SSL_RESPONSE_REJECT); + } + + public function testMySqlSslFlagConstant(): void + { + $this->assertSame(0x00000800, TLS::MYSQL_CLIENT_SSL_FLAG); + } + + public function testDefaultCiphersContainsModernSuites(): void + { + $this->assertStringContainsString('ECDHE-ECDSA-AES128-GCM-SHA256', TLS::DEFAULT_CIPHERS); + $this->assertStringContainsString('ECDHE-RSA-AES256-GCM-SHA384', TLS::DEFAULT_CIPHERS); + $this->assertStringContainsString('CHACHA20-POLY1305', TLS::DEFAULT_CIPHERS); + } + + public function testValidatePassesWithReadableFiles(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $tls = new TLS(certificate: $certFile, key: $keyFile); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidateThrowsForUnreadableCert(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS certificate file not readable'); + + $tls = new TLS(certificate: '/nonexistent/cert.crt', key: '/tmp/key.key'); + $tls->validate(); + } + + public function testValidateThrowsForUnreadableKey(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS private key file not readable'); + + $tls = new TLS(certificate: $certFile, key: '/nonexistent/key.key'); + $tls->validate(); + } finally { + unlink($certFile); + } + } + + public function testValidateThrowsWhenClientCertRequiredButNoCaPath(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('CA certificate path is required when client certificate verification is enabled'); + + $tls = new TLS( + certificate: $certFile, + key: $keyFile, + requireClientCert: true, + ); + $tls->validate(); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidateThrowsForUnreadableCaFile(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS CA certificate file not readable'); + + $tls = new TLS( + certificate: $certFile, + key: $keyFile, + ca: '/nonexistent/ca.crt', + ); + $tls->validate(); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidatePassesWithAllReadableFiles(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + $caFile = tempnam(sys_get_temp_dir(), 'ca_'); + + try { + $tls = new TLS( + certificate: $certFile, + key: $keyFile, + ca: $caFile, + requireClientCert: true, + ); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + unlink($caFile); + } + } + + public function testValidateCaPathOptionalWithoutClientCert(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + // ca is empty and requireClientCert is false β€” should pass + $tls = new TLS(certificate: $certFile, key: $keyFile); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testIsMutualTLSReturnsTrueWhenBothConditionsMet(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + requireClientCert: true, + ); + + $this->assertTrue($tls->isMutual()); + } + + public function testIsMutualTLSReturnsFalseWhenClientCertNotRequired(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + ca: '/certs/ca.crt', + requireClientCert: false, + ); + + $this->assertFalse($tls->isMutual()); + } + + public function testIsMutualTLSReturnsFalseWhenCaPathEmpty(): void + { + $tls = new TLS( + certificate: '/certs/server.crt', + key: '/certs/server.key', + requireClientCert: true, + ); + + $this->assertFalse($tls->isMutual()); + } + + public function testIsMutualTLSReturnsFalseWithDefaults(): void + { + $tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key'); + + $this->assertFalse($tls->isMutual()); + } + + public function testIsPostgreSQLSSLRequestWithValidData(): void + { + $this->assertTrue(TLS::isPostgreSQLSSLRequest(TLS::PG_SSL_REQUEST)); + } + + public function testIsPostgreSQLSSLRequestWithTooShortData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest("\x00\x00\x00\x08\x04\xd2\x16")); + } + + public function testIsPostgreSQLSSLRequestWithTooLongData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest(TLS::PG_SSL_REQUEST . "\x00")); + } + + public function testIsPostgreSQLSSLRequestWithEmptyData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest('')); + } + + public function testIsPostgreSQLSSLRequestWithWrongBytes(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest("\x00\x00\x00\x08\x00\x00\x00\x00")); + } + + public function testIsPostgreSQLSSLRequestWithRegularStartupMessage(): void + { + // A regular PostgreSQL startup message (protocol version 3.0) + $startup = "\x00\x00\x00\x08\x00\x03\x00\x00"; + $this->assertFalse(TLS::isPostgreSQLSSLRequest($startup)); + } + + public function testIsMySQLSSLRequestWithValidData(): void + { + // Build a valid MySQL SSL request: 36+ bytes, sequence ID 1, SSL flag set + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // Set CLIENT_SSL flag (0x0800) at offset 4-5 (little-endian) + $data[4] = "\x00"; + $data[5] = "\x08"; // 0x0800 in little-endian + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithTooShortData(): void + { + $this->assertFalse(TLS::isMySQLSSLRequest(str_repeat("\x00", 35))); + } + + public function testIsMySQLSSLRequestWithEmptyData(): void + { + $this->assertFalse(TLS::isMySQLSSLRequest('')); + } + + public function testIsMySQLSSLRequestWithWrongSequenceId(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x02"; // sequence ID = 2 (should be 1) + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithoutSslFlag(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // No SSL flag + $data[4] = "\x00"; + $data[5] = "\x00"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithSslFlagAndOtherFlags(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // SSL flag (0x0800) combined with other flags (0xFF) + $data[4] = "\xFF"; + $data[5] = "\x0F"; // includes 0x0800 + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithSequenceIdZero(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x00"; // sequence ID = 0 + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithExactly36Bytes(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithLargerPacket(): void + { + $data = str_repeat("\x00", 100); + $data[3] = "\x01"; + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } +}