diff --git a/docker-compose.example.yml b/docker-compose.example.yml index c466ebe0..1b895c65 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -119,7 +119,11 @@ services: environment: AUTO_COMPOSER_INSTALL: "true" EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} +<<<<<<< HEAD + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-} +======= EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} +>>>>>>> origin/master volumes: - ./services/nginx/app:/var/www/html - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro @@ -140,7 +144,11 @@ services: environment: AUTO_COMPOSER_INSTALL: "false" EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} +<<<<<<< HEAD + EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-} +======= EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env} +>>>>>>> origin/master volumes: - ./services/nginx/app:/var/www/html - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro diff --git a/services/edge-broker/test/config.test.mjs b/services/edge-broker/test/config.test.mjs index 6adb091d..92e9604b 100644 --- a/services/edge-broker/test/config.test.mjs +++ b/services/edge-broker/test/config.test.mjs @@ -7,6 +7,11 @@ import { fileURLToPath } from "node:url"; const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDirectory, "../../.."); +<<<<<<< HEAD +test("traefik defines a dedicated edge broker entrypoint on port 4300", () => { + assert.match(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/); + assert.match(traefikProdSource, /edge-broker:\s*\n\s*address:\s*":4300"/); +======= function readRequiredSource(...pathSegments) { const sourcePath = path.resolve(repoRoot, ...pathSegments); assert.equal(existsSync(sourcePath), true, `Expected config fixture to exist: ${sourcePath}`); @@ -34,6 +39,7 @@ function readComposeServiceBlock(composeSource, serviceName) { test("traefik does not expose a dedicated public edge broker port", () => { assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/); +>>>>>>> origin/master }); <<<<<<< HEAD @@ -42,6 +48,13 @@ test("base docker compose binds edge broker port 4300 to localhost only", () => assert.match(baseComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "127.0.0.1:4300:4300"/); }); +<<<<<<< HEAD +test("php services receive broker url defaults and require explicit broker shared secret", () => { + assert.match(composeSource, /php1:[\s\S]*EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); + assert.match(composeSource, /php1:[\s\S]*EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-\}/); + assert.match(composeSource, /php-staging:[\s\S]*EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); + assert.match(composeSource, /php-cron:[\s\S]*EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-\}/); +======= test("example docker compose binds edge broker port 4300 to localhost only", () => { assert.match(exampleComposeSource, /\bedge-broker:\b/); assert.match(exampleComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "127.0.0.1:4300:4300"/); @@ -101,4 +114,5 @@ test("base docker compose wires the broker into each php worker", () => { assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/); } +>>>>>>> origin/master }); diff --git a/services/nginx/app/classes/edge_broker_client.php b/services/nginx/app/classes/edge_broker_client.php new file mode 100644 index 00000000..ff8d2cba --- /dev/null +++ b/services/nginx/app/classes/edge_broker_client.php @@ -0,0 +1,145 @@ +curlErrno; + } +} + +class edge_broker_http_exception extends Exception +{ + public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public function statusCode(): int + { + return $this->statusCode; + } +} + +class edge_broker_client +{ + private const DEFAULT_BROKER_URL = 'http://edge-broker:4300'; + + public function __construct( + private readonly ?string $baseUrl = null, + private readonly ?string $sharedSecret = null, + private readonly int $timeoutSeconds = 10 + ) { + } + + public function isConfigured(): bool + { + return trim((string)$this->resolveBaseUrl()) !== ''; + } + + public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands'; + $response = $this->request('POST', $url, [ + 'commandType' => $commandType, + 'payload' => $payload, + ]); + + return is_array($response) ? $response : ['ok' => false, 'response' => $response]; + } + + public function validateAgent(int $gatewayId, string $agentToken): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth'; + $response = $this->request('POST', $url, [ + 'gatewayId' => $gatewayId, + 'agentToken' => $agentToken, + ]); + + return is_array($response) ? $response : []; + } + + public function validateShellSession(string $sessionToken): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth'; + $response = $this->request('POST', $url, [ + 'sessionToken' => $sessionToken, + ]); + + return is_array($response) ? $response : []; + } + + public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array + { + $url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close'; + $response = $this->request('POST', $url, [ + 'sessionToken' => $sessionToken, + 'transcript' => $transcript, + 'closedReason' => $closedReason, + ]); + + return is_array($response) ? $response : []; + } + + private function resolveBaseUrl(): string + { + return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL)); + } + + private function resolveSharedSecret(): string + { + return trim((string)($this->sharedSecret + ?? getenv('EDGE_BROKER_SHARED_SECRET') + ?: getenv('EDGE_INTERNAL_SECRET') + ?: '')); + } + + /** + * @throws Exception + */ + private function request(string $method, string $url, array $payload): array|object|null + { + if (trim($url) === '') { + throw new Exception('Edge broker URL is not configured'); + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds); + curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_filter([ + 'Content-Type: application/json', + $this->resolveSharedSecret() !== '' ? 'X-Edge-Broker-Secret: ' . $this->resolveSharedSecret() : null, + ]))); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + + $rawResponse = curl_exec($ch); + $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($rawResponse === false) { + throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno); + } + + $decoded = json_decode((string)$rawResponse, true); + if ($statusCode >= 400) { + $message = is_array($decoded) + ? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed') + : 'Edge broker request failed'; + throw new edge_broker_http_exception($message, $statusCode); + } + + return $decoded; + } +} diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php new file mode 100644 index 00000000..13d1c1d4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeBrokerClientConfigTest.php @@ -0,0 +1,13 @@ +not->toBeFalse(); + expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';"); + expect($source)->toContain('class edge_broker_transport_exception extends Exception'); + expect($source)->toContain('class edge_broker_http_exception extends Exception'); + expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')"); + expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')"); + expect($source)->not->toContain('DEFAULT_SHARED_SECRET'); +});