diff --git a/composer.json b/composer.json index c9c7782d..285abb52 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "ext-pcntl": "*", "adbario/php-dot-notation": "^3.1", "amphp/cache": "^2.0", + "amphp/cluster": "^2.0", "amphp/file": "^v3.0.0", "amphp/http-client": "^v5.0.1", "amphp/http-server": "^v3.2.0", diff --git a/src/App.php b/src/App.php index ac153e96..9071f6fb 100644 --- a/src/App.php +++ b/src/App.php @@ -4,43 +4,70 @@ namespace Phenix; +use Amp\Cluster\Cluster; use Amp\Http\Server\DefaultErrorHandler; +use Amp\Http\Server\Driver\ConnectionLimitingClientFactory; +use Amp\Http\Server\Driver\ConnectionLimitingServerSocketFactory; +use Amp\Http\Server\Driver\SocketClientFactory; use Amp\Http\Server\Middleware; +use Amp\Http\Server\Middleware\CompressionMiddleware; +use Amp\Http\Server\Middleware\ForwardedHeaderType; use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Router; use Amp\Http\Server\SocketHttpServer; -use Amp\Socket; +use Amp\Socket\BindContext; +use Amp\Socket\Certificate; +use Amp\Socket\ServerTlsContext; +use Amp\Sync\LocalSemaphore; use League\Container\Container; use League\Uri\Uri; use Mockery\LegacyMockInterface; use Mockery\MockInterface; use Monolog\Logger; use Phenix\Console\Phenix; +use Phenix\Constants\AppMode; +use Phenix\Constants\ServerMode; use Phenix\Contracts\App as AppContract; use Phenix\Contracts\Makeable; +use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Constants\Protocol; use Phenix\Logging\LoggerFactory; use Phenix\Runtime\Log; use Phenix\Session\SessionMiddlewareFactory; +use function Amp\async; +use function Amp\trapSignal; +use function count; +use function extension_loaded; +use function is_array; + class App implements AppContract, Makeable { - private static string $path; + protected static string $path; + + protected static Container $container; + + protected string $host; + + protected RequestHandler $router; - private static Container $container; + protected Logger $logger; - private string $host; + protected SocketHttpServer $server; - private RequestHandler $router; + protected bool $signalTrapping = true; - private Logger $logger; + protected DefaultErrorHandler $errorHandler; - private SocketHttpServer $server; + protected Protocol $protocol = Protocol::HTTP; - private bool $signalTrapping = true; + protected AppMode $appMode; - private DefaultErrorHandler $errorHandler; + protected ServerMode $serverMode; + + protected bool $isRunning = false; public function __construct(string $path) { @@ -57,8 +84,6 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); - $this->host = $this->getHost(); - self::$container->add(Phenix::class)->addMethodCall('registerCommands'); /** @var array $providers */ @@ -68,28 +93,39 @@ public function setup(): void self::$container->addServiceProvider(new $provider()); } - /** @var string $channel */ - $channel = Config::get('logging.default', 'file'); - - $this->logger = LoggerFactory::make($channel); + $this->serverMode = ServerMode::tryFrom(Config::get('app.server_mode', ServerMode::SINGLE->value)) ?? ServerMode::SINGLE; - $this->register(Log::class, new Log($this->logger)); + $this->setLogger(); } public function run(): void { - $this->server = SocketHttpServer::createForDirectAccess($this->logger); + $this->appMode = AppMode::tryFrom(Config::get('app.app_mode', AppMode::DIRECT->value)) ?? AppMode::DIRECT; - $this->setRouter(); + $this->detectProtocol(); + + $this->host = Uri::new(Config::get('app.url'))->getHost(); - $port = $this->getPort(); + $this->server = $this->createServer(); - $this->server->expose(new Socket\InternetAddress($this->host, $port)); + $this->setRouter(); + + $this->expose(); $this->server->start($this->router, $this->errorHandler); - if ($this->signalTrapping) { - $signal = \Amp\trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); + $this->isRunning = true; + + if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) { + async(function (): void { + Cluster::awaitTermination(); + + $this->logger->info('Received termination request'); + + $this->stop(); + }); + } elseif ($this->signalTrapping) { + $signal = trapSignal([SIGHUP, SIGINT, SIGQUIT, SIGTERM]); $this->logger->info("Caught signal {$signal}, stopping server"); @@ -99,7 +135,11 @@ public function run(): void public function stop(): void { - $this->server->stop(); + if ($this->isRunning) { + $this->server->stop(); + + $this->isRunning = false; + } } public static function make(string $key): object @@ -142,7 +182,17 @@ public function disableSignalTrapping(): void $this->signalTrapping = false; } - private function setRouter(): void + protected function setLogger(): void + { + /** @var string $channel */ + $channel = Config::get('logging.default', 'file'); + + $this->logger = LoggerFactory::make($channel, $this->serverMode); + + $this->register(Log::class, new Log($this->logger)); + } + + protected function setRouter(): void { $router = new Router($this->server, $this->logger, $this->errorHandler); @@ -174,29 +224,109 @@ private function setRouter(): void $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } - private function getHost(): string + protected function createServer(): SocketHttpServer { - return $this->getHostFromOptions() ?? Uri::new(Config::get('app.url'))->getHost(); + if ($this->serverMode === ServerMode::CLUSTER) { + return $this->createClusterServer(); + } + + if ($this->appMode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); + + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } + + return SocketHttpServer::createForBehindProxy( + $this->logger, + ForwardedHeaderType::XForwardedFor, + $trustedProxies + ); + } + + return SocketHttpServer::createForDirectAccess($this->logger); } - private function getPort(): int + protected function createClusterServer(): SocketHttpServer { - $port = $this->getPortFromOptions() ?? Config::get('app.port'); + $middleware = []; + $allowedMethods = Middleware\AllowedMethodsMiddleware::DEFAULT_ALLOWED_METHODS; + + if (extension_loaded('zlib')) { + $middleware[] = new CompressionMiddleware(); + } + + if ($this->appMode === AppMode::PROXIED) { + /** @var array $trustedProxies */ + $trustedProxies = Config::get('app.trusted_proxies', []); - return (int) $port; + if (is_array($trustedProxies) && count($trustedProxies) === 0) { + throw new RuntimeError('Trusted proxies must be an array of IP addresses or CIDRs.'); + } + + $middleware[] = new Middleware\ForwardedMiddleware(ForwardedHeaderType::XForwardedFor, $trustedProxies); + + return new SocketHttpServer( + $this->logger, + Cluster::getServerSocketFactory(), + new SocketClientFactory($this->logger), + $middleware, + $allowedMethods, + ); + } + + $connectionLimit = 1000; + $connectionLimitPerIp = 10; + + $serverSocketFactory = new ConnectionLimitingServerSocketFactory( + new LocalSemaphore($connectionLimit), + Cluster::getServerSocketFactory(), + ); + + $clientFactory = new ConnectionLimitingClientFactory( + new SocketClientFactory($this->logger), + $this->logger, + $connectionLimitPerIp, + ); + + return new SocketHttpServer( + $this->logger, + $serverSocketFactory, + $clientFactory, + $middleware, + $allowedMethods, + ); } - private function getHostFromOptions(): string|null + protected function expose(): void { - $options = getopt('', ['host:']); + $port = (int) Config::get('app.port'); + $plainBindContext = (new BindContext())->withTcpNoDelay(); + + if ($this->protocol === Protocol::HTTPS) { + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); - return $options['host'] ?? null; + $tlsBindContext = $plainBindContext->withTlsContext( + (new ServerTlsContext())->withDefaultCertificate(new Certificate($certPath)) + ); + + $this->server->expose("{$this->host}:{$port}", $tlsBindContext); + + return; + } + + $this->server->expose("{$this->host}:{$port}", $plainBindContext); } - private function getPortFromOptions(): string|null + protected function detectProtocol(): void { - $options = getopt('', ['port:']); + $url = (string) Config::get('app.url'); + + /** @var string|null $certPath */ + $certPath = Config::get('app.cert_path'); - return $options['port'] ?? null; + $this->protocol = str_starts_with($url, 'https://') && $certPath !== null ? Protocol::HTTPS : Protocol::HTTP; } } diff --git a/src/Constants/AppMode.php b/src/Constants/AppMode.php new file mode 100644 index 00000000..2758ffec --- /dev/null +++ b/src/Constants/AppMode.php @@ -0,0 +1,12 @@ +address = $request->getClient()->getRemoteAddress()->toString(); - if ($forwardingHeader = $request->getHeader('X-Forwarded-For')) { - $parts = array_map(static fn ($v) => trim($v), explode(',', $forwardingHeader)); - $this->forwardingAddresses = $parts; + if ($request->hasAttribute(Forwarded::class) && $forwarded = $request->getAttribute(Forwarded::class)) { + $this->forwardingAddress = $forwarded->getFor()->toString(); } } @@ -51,12 +51,12 @@ public function port(): int|null public function isForwarded(): bool { - return ! empty($this->forwardingAddresses); + return ! empty($this->forwardingAddress); } - public function forwardingAddresses(): array + public function forwardingAddress(): string|null { - return $this->forwardingAddresses; + return $this->forwardingAddress; } public function hash(): string diff --git a/src/Http/Middlewares/ResponseHeaders.php b/src/Http/Middlewares/ResponseHeaders.php index 05ce5e17..bdca17a6 100644 --- a/src/Http/Middlewares/ResponseHeaders.php +++ b/src/Http/Middlewares/ResponseHeaders.php @@ -21,7 +21,7 @@ class ResponseHeaders implements Middleware public function __construct() { - $builders = Config::get('server.security.headers', []); + $builders = Config::get('app.response.headers', []); foreach ($builders as $builder) { assert(is_subclass_of($builder, HeaderBuilder::class)); diff --git a/src/Http/Request.php b/src/Http/Request.php index 82042965..ef4c37b2 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -42,6 +42,8 @@ class Request implements Arrayable protected Session|null $session; + protected Ip|null $ip; + public function __construct( protected ServerRequest $request ) { @@ -149,7 +151,7 @@ public function session(string|null $key = null, array|string|int|null $default public function ip(): Ip { - return Ip::make($this->request); + return $this->ip ??= Ip::make($this->request); } public function toArray(): array diff --git a/src/Logging/LoggerFactory.php b/src/Logging/LoggerFactory.php index 5dafb2fe..238812ee 100644 --- a/src/Logging/LoggerFactory.php +++ b/src/Logging/LoggerFactory.php @@ -5,11 +5,13 @@ namespace Phenix\Logging; use Amp\ByteStream; +use Amp\Cluster\Cluster; use Amp\Log\ConsoleFormatter; use Amp\Log\StreamHandler; use Monolog\Formatter\LineFormatter; use Monolog\Logger; use Monolog\Processor\PsrLogMessageProcessor; +use Phenix\Constants\ServerMode; use Phenix\Contracts\Makeable; use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; @@ -17,15 +19,19 @@ class LoggerFactory implements Makeable { - public static function make(string $key): Logger + public static function make(string $key, ServerMode $serverMode = ServerMode::SINGLE): Logger { - $logHandler = match ($key) { - 'file' => self::fileHandler(), - 'stream' => self::streamHandler(), - default => throw new RuntimeError("Unsupported logging channel: {$key}") - }; + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + $logHandler = Cluster::createLogHandler(); + } else { + $logHandler = match ($key) { + 'file' => self::fileHandler(), + 'stream' => self::streamHandler(), + default => throw new RuntimeError("Unsupported logging channel: {$key}"), + }; + } - $logger = new Logger('phenix'); + $logger = new Logger(self::buildName($serverMode)); $logger->pushHandler($logHandler); return $logger; @@ -56,4 +62,13 @@ private static function fileHandler(): StreamHandler return $logHandler; } + + private static function buildName(ServerMode $serverMode = ServerMode::SINGLE): string + { + if ($serverMode === ServerMode::CLUSTER && Cluster::isWorker()) { + return 'phenix-worker-' . (Cluster::getContextId() ?? getmypid()); + } + + return 'phenix'; + } } diff --git a/src/Testing/Concerns/InteractWithResponses.php b/src/Testing/Concerns/InteractWithResponses.php index 8fe1092b..cf4235a9 100644 --- a/src/Testing/Concerns/InteractWithResponses.php +++ b/src/Testing/Concerns/InteractWithResponses.php @@ -4,9 +4,18 @@ namespace Phenix\Testing\Concerns; +use Amp\Cancellation; +use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\Form; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; +use Amp\Socket\DnsSocketConnector; +use Amp\Socket\Socket; +use Amp\Socket\SocketAddress; +use Amp\Socket\SocketConnector; use Phenix\Http\Constants\HttpMethod; use Phenix\Testing\TestResponse; use Phenix\Util\URL; @@ -37,7 +46,22 @@ public function call( $request->setBody($body); } - $client = HttpClientBuilder::buildDefault(); + $connector = new class () implements SocketConnector { + public function connect( + SocketAddress|string $uri, + ConnectContext|null $context = null, + Cancellation|null $cancellation = null + ): Socket { + $context = (new ConnectContext()) + ->withTlsContext((new ClientTlsContext(''))->withoutPeerVerification()); + + return (new DnsSocketConnector())->connect($uri, $context, $cancellation); + } + }; + + $client = (new HttpClientBuilder()) + ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))) + ->build(); return new TestResponse($client->request($request)); } diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 932b0465..1ef400b8 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -62,6 +62,10 @@ protected function tearDown(): void Cache::clear(); } + if ($this->app instanceof AppProxy) { + $this->app->stop(); + } + $this->app = null; } diff --git a/tests/Feature/AppClusterTest.php b/tests/Feature/AppClusterTest.php new file mode 100644 index 00000000..87f8a50e --- /dev/null +++ b/tests/Feature/AppClusterTest.php @@ -0,0 +1,27 @@ +value; +}); + +it('starts server in cluster mode', function (): void { + + Config::set('app.server_mode', ServerMode::CLUSTER->value); + + Route::get('/cluster', fn (): Response => response()->json(['message' => 'Cluster'])); + + $this->app->run(); + + $this->get('/cluster') + ->assertOk() + ->assertJsonPath('data.message', 'Cluster'); + + $this->app->stop(); +}); diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php new file mode 100644 index 00000000..e6be0237 --- /dev/null +++ b/tests/Feature/AppTest.php @@ -0,0 +1,49 @@ +value); + Config::set('app.trusted_proxies', ['127.0.0.1/32', '127.0.0.1']); + + Route::get('/proxy', function (Request $request): Response { + return response()->json(['message' => 'Proxied']); + }); + + $this->app->run(); + + $this->get('/proxy', headers: ['X-Forwarded-For' => '10.0.0.1']) + ->assertOk() + ->assertJsonPath('data.message', 'Proxied'); + + $this->app->stop(); +}); + +it('starts server in proxied mode with no trusted proxies', function (): void { + Config::set('app.app_mode', AppMode::PROXIED->value); + + $this->app->run(); +})->throws(RuntimeError::class); + +it('starts server with TLS certificate', function (): void { + Config::set('app.url', 'https://127.0.0.1'); + Config::set('app.port', 1338); + Config::set('app.cert_path', __DIR__ . '/../fixtures/files/cert.pem'); + + Route::get('/tls', fn (): Response => response()->json(['message' => 'TLS'])); + + $this->app->run(); + + $this->get('/tls') + ->assertOk() + ->assertJsonPath('data.message', 'TLS'); + + $this->app->stop(); +}); diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index 0eaa38b8..d20ba005 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -465,13 +465,13 @@ $this->get('/secure') ->assertOk() ->assertHeaders([ - 'X-Frame-Options' => 'SAMEORIGIN', - 'X-Content-Type-Options' => 'nosniff', - 'X-DNS-Prefetch-Control' => 'off', - 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', - 'Referrer-Policy' => 'no-referrer', - 'Cross-Origin-Resource-Policy' => 'same-origin', - 'Cross-Origin-Opener-Policy' => 'same-origin', + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-DNS-Prefetch-Control' => 'off', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Referrer-Policy' => 'no-referrer', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'Cross-Origin-Opener-Policy' => 'same-origin', ]); }); diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php index 7e35b3d3..ab1fa30c 100644 --- a/tests/Unit/Http/IpAddressTest.php +++ b/tests/Unit/Http/IpAddressTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Amp\Http\Server\Driver\Client; +use Amp\Http\Server\Middleware\Forwarded; use Amp\Http\Server\Request as ServerRequest; +use Amp\Socket\InternetAddress; use Amp\Socket\SocketAddress; use Amp\Socket\SocketAddressType; use League\Uri\Http; @@ -43,7 +45,7 @@ public function __toString(): string expect($ip->hash())->toBe($expected); expect($ip->isForwarded())->toBeFalse(); - expect($ip->forwardingAddresses())->toBe([]); + expect($ip->forwardingAddress())->toBeNull(); })->with([ ['192.168.1.1', hash('sha256', '192.168.1.1')], ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], @@ -89,7 +91,7 @@ public function __toString(): string expect($ip->host())->toBe('2001:db8::1'); expect($ip->port())->toBe(443); expect($ip->isForwarded())->toBeFalse(); - expect($ip->forwardingAddresses())->toBe([]); + expect($ip->forwardingAddress())->toBeNull(); }); it('parses host only from raw IPv6 without port', function (): void { @@ -246,10 +248,19 @@ public function __toString(): string ); $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); - $request->setHeader('X-Forwarded-For', '203.0.113.1, 198.51.100.2'); + $request->setHeader('X-Forwarded-For', '203.0.113.1'); + $request->setAttribute( + Forwarded::class, + new Forwarded( + new InternetAddress('203.0.113.1', 4711), + [ + 'for' => '203.0.113.1:4711', + ] + ) + ); $ip = Ip::make($request); expect($ip->isForwarded())->toBeTrue(); - expect($ip->forwardingAddresses())->toBe(['203.0.113.1', '198.51.100.2']); + expect($ip->forwardingAddress())->toBe('203.0.113.1:4711'); }); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 370af3fa..f956ea40 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -6,9 +6,50 @@ 'name' => env('APP_NAME', static fn (): string => 'Phenix'), 'env' => env('APP_ENV', static fn (): string => 'local'), 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), - 'port' => env('APP_PORT', static fn (): int => 1338), + 'port' => env('APP_PORT', static fn (): int => 1337), + 'cert_path' => env('APP_CERT_PATH', static fn (): string|null => null), 'key' => env('APP_KEY'), 'previous_key' => env('APP_PREVIOUS_KEY'), + + /* + |-------------------------------------------------------------------------- + | App mode + |-------------------------------------------------------------------------- + | Controls how the HTTP server determines client connection details. + | + | direct: + | The server is exposed directly to clients. Remote address, scheme, + | and host are taken from the TCP connection and request line. + | + | proxied: + | The server runs behind a reverse proxy or load balancer (e.g., Nginx, + | HAProxy, AWS ALB). Client information is derived from standard + | forwarding headers only when the request comes from a trusted proxy. + | Configure trusted proxies in `trusted_proxies` (IP addresses or CIDRs). + | When enabled, the server will honor `Forwarded`, `X-Forwarded-For`, + | `X-Forwarded-Proto`, and `X-Forwarded-Host` headers from trusted + | sources, matching Amphp's behind-proxy behavior. + | + | Supported values: "direct", "proxied" + | + */ + + 'app_mode' => env('APP_MODE', static fn (): string => 'direct'), + 'trusted_proxies' => env('APP_TRUSTED_PROXIES', static fn (): array => []), + + /* + |-------------------------------------------------------------------------- + | Server runtime mode + |-------------------------------------------------------------------------- + | Controls whether the HTTP server runs as a single process (default) or + | under amphp/cluster. + | + | Supported values: + | - "single" (single process) + | - "cluster" (run with vendor/bin/cluster and cluster sockets) + | + */ + 'server_mode' => env('APP_SERVER_MODE', static fn (): string => 'single'), 'debug' => env('APP_DEBUG', static fn (): bool => true), 'locale' => 'en', 'fallback_locale' => 'en', @@ -39,4 +80,15 @@ \Phenix\Translation\TranslationServiceProvider::class, \Phenix\Validation\ValidationServiceProvider::class, ], + 'response' => [ + 'headers' => [ + \Phenix\Http\Headers\XDnsPrefetchControl::class, + \Phenix\Http\Headers\XFrameOptions::class, + \Phenix\Http\Headers\StrictTransportSecurity::class, + \Phenix\Http\Headers\XContentTypeOptions::class, + \Phenix\Http\Headers\ReferrerPolicy::class, + \Phenix\Http\Headers\CrossOriginResourcePolicy::class, + \Phenix\Http\Headers\CrossOriginOpenerPolicy::class, + ], + ], ]; diff --git a/tests/fixtures/application/config/server.php b/tests/fixtures/application/config/server.php deleted file mode 100644 index b46930f1..00000000 --- a/tests/fixtures/application/config/server.php +++ /dev/null @@ -1,25 +0,0 @@ - [ - 'headers' => [ - XDnsPrefetchControl::class, - XFrameOptions::class, - StrictTransportSecurity::class, - XContentTypeOptions::class, - ReferrerPolicy::class, - CrossOriginResourcePolicy::class, - CrossOriginOpenerPolicy::class, - ], - ], -]; diff --git a/tests/fixtures/files/cert.pem b/tests/fixtures/files/cert.pem new file mode 100644 index 00000000..2a0743d7 --- /dev/null +++ b/tests/fixtures/files/cert.pem @@ -0,0 +1,47 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhYCCQCltBFjDvGeajANBgkqhkiG9w0BAQUFADBZMQswCQYDVQQGEwJM +VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMTUxMTA0MTg0NzI4WhcN +NDMwMzIxMTg0NzI4WjBZMQswCQYDVQQGEwJMVTETMBEGA1UECAwKU29tZS1TdGF0 +ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAls +b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC52v3CH7MO +mNwpXNUCtwtaEr25Iq+Gp60/jlvcCU/ZvW/N3DS6YCUepTFgzut+VKLRrfN1mC3I +jLq4ieXFb/5wd3b9Q+P8D913zF9SvXliXIbIuZJrx7Du9Gb1Y0AUmZ3CZSkOdUP4 +svxL3dGlf2z9CshAuJJlYdaujTT1E0yZaI9hyvmcKHTBOhHwW57gO89usnM9TMYK +CjnqnHBX84SOwkG0Jvkmtl5ideZnV9Y1OwfOyWEJ9TKvia4YEEH0ZmChlqDonZpB +NAFBxNx6x2qvcHVLUDRBZD2v0cGSzNbzeVfq7zZiOqvLNjk3gr84RK3qdIKDwlHo +tu0fMJDNMhrBAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAD5TI3rjaupcy/frGIjl +kUSbIP6XUKd2ja49Ifa7uifl4orR7pw0FRj0mnqoPcDh1glr5XtC9TpIYRyTNxSg +FAqf5KU6HdWwmqTQqoMIBBPeG62WkhwhtaZ0+KwZ6bZyJ5YNOxNLpvjbmpKEPJ0H +W01Rr7lw+IPSKJm6wPcZ5Pke42H91N5Ya1BSv5utjMaqNpz1+3wetslTxDAYqgXx +RazVVGxwo2XMygRnK3amMZCA0x71/rVK1sxZJyOPCgH9vzOPPqgAirPGnDYiY5Kr +Z3NsQ+IHr/HC3kt6hM+vVpkEXbkrzXMJxNuduyGyXhFJHSigksRXsFryrI4iAxMP +LHw= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAudr9wh+zDpjcKVzVArcLWhK9uSKvhqetP45b3AlP2b1vzdw0 +umAlHqUxYM7rflSi0a3zdZgtyIy6uInlxW/+cHd2/UPj/A/dd8xfUr15YlyGyLmS +a8ew7vRm9WNAFJmdwmUpDnVD+LL8S93RpX9s/QrIQLiSZWHWro009RNMmWiPYcr5 +nCh0wToR8Fue4DvPbrJzPUzGCgo56pxwV/OEjsJBtCb5JrZeYnXmZ1fWNTsHzslh +CfUyr4muGBBB9GZgoZag6J2aQTQBQcTcesdqr3B1S1A0QWQ9r9HBkszW83lX6u82 +YjqryzY5N4K/OESt6nSCg8JR6LbtHzCQzTIawQIDAQABAoIBAQC5BwDMqzxa0umU +MDxMWKjvgmrpDlQKzZHYDUT8WTTqxAKzwn+n8KHj0XfINhgSi/YQo4oWT2t9FkWq +BHcAyY9YrkaCu30Ua0MDyi44NDPNLeptmPnhXUuTiTObJrUcDRcW+hkWsL37sU0l +xm65wZNik8JrVJVCY1YULrZDKnR+3/tLuOhaB8u3/OPYLQ1GfRcIy+ghAuNTJ2be +1Jxl1pOlhlNkTdSV0U+r+jxLLtBwpiTdQamdW85reAN9sAAk+I/DuCeWIhMLrm8p +CeemRPA50fwNg+PQA3yUbcj7Mm+/nkyDw7DDG9YK8qpEhbCZnX8FQQSUDCi3P0i7 +HSPJbwABAoGBAOnCOfsht6678ZXaUet25dXQ8kUCAXZqpzAFqxsyH/oi9hDgsZ+E +oPPa2158lGyov+Xv149LDyXJIFUW96TJoMrMnLJNu/51MsHScx1/yCybEVH/Sre7 +PsO1NNuJfP1vP7RjVLVpGJ0KoYwdjmH+czRHJKYZJwkCFlzI2KqhD9rBAoGBAMuJ +9jt72ToBV2Uz9dFb1kPsVI/5e3wL15hcp+57rSdNBjzki4z6zV4kO6rQAuUWcmBn +oKmfRKyuw6iz/Xl8PXxjxlWluUXEdTBivWGkuZZoBAHVe8yvXQeImvBmG+qJhJud +iz0YLL6/2yT9C4t0D5WaWaKx7Z66yLoDUacr8kABAoGAFzsPKg76wym4Y40T0RO6 +2ZnvSb5eSNdmkBYwH/7GQMSSsbCy1kiG+lUIsgYtdfL7Ry2jvYDXG4k2Zl5m9AB5 +s03MUMf649nf1nVErWzShuROP1jgowu/vBFZFGxAeKtCqHmqpHCyWoEA9vzE9qYj +6tEbKkqbn4COml/3cFWbTsECgYEAkgFxZNI+zWFQ9AQF/hzG4wqQzobEkgNcsKsm +u+h0GZEjPGMlyAfRcgrD0pBMw1EK0yUDFyps9QKY0FftKEx7PtPD3oR3FxkKh58N +AxJLHx2WYkpl+DqDnXfczT4yIFhti8PDshu5XUv7Q9lRgsPKuiJy0kaYkhijDOx8 +klLwAAECgYBz3f6AiBxqYe1AKmEFuveC9ulMDCBEgAbhp1Dgx9zW7ZzF+YxtrcMh +akCozVQUEiD11q7bdEZL8jlVLsqrT+Xlfdr/AnICK55FvQdDt6hacLXBWdFDtMx1 +Dld3Erx9aLeKO081voSpK63h91DEWph1YtU+ecB9bq/Pui01sYD4CA== +-----END RSA PRIVATE KEY----- \ No newline at end of file