#!/usr/bin/env php data) ? $this->data[$key] : $default; } public function set(string $key, mixed $value): void { $this->data[$key] = $value; } public function save(): void { file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } } final class HttpJsonClient { public function __construct(private readonly string $baseUrl) { } public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array { $response = $this->requestJson( 'POST', rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'), $payload, $timeoutSeconds ); return $response['decoded']; } public function getJson(string $url, int $timeoutSeconds = 10): array { $response = $this->requestJson('GET', $url, null, $timeoutSeconds); return $response['decoded'] ?? []; } public function download(string $url, int $timeoutSeconds = 60): string { $response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']); return $response['body']; } private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array { $headers = ['Accept: application/json']; if ($payload !== null) { $headers[] = 'Content-Type: application/json'; } $response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers); $decoded = json_decode($response['body'], true); if (!is_array($decoded)) { throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url); } $response['decoded'] = $decoded; return $response; } private function requestRaw( string $method, string $url, ?array $payload, int $timeoutSeconds, array $headers ): array { $responseHeaders = []; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeoutSeconds, CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds), CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int { $trimmed = trim($headerLine); if ($trimmed !== '') { $responseHeaders[] = $trimmed; } return strlen($headerLine); }, ]); if ($payload !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES)); } $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $error = curl_error($ch); curl_close($ch); if ($raw === false) { throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error')); } if ($status >= 400) { $preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240); $headerPreview = implode('; ', array_slice($responseHeaders, 0, 8)); throw new RuntimeException(sprintf( '%s %s returned HTTP %d. Headers: %s. Body preview: %s', $method, $url, $status, $headerPreview !== '' ? $headerPreview : 'none', $preview !== '' ? $preview : 'empty' )); } return [ 'status' => $status, 'body' => (string)$raw, 'headers' => $responseHeaders, ]; } } final class Logger { public function __construct(private readonly string $logFile) { } public function info(string $message): void { $this->write('INFO', $message); } public function warning(string $message): void { $this->write('WARNING', $message); } public function error(string $message): void { $this->write('ERROR', $message); } private function write(string $level, string $message): void { $line = '[' . date('c') . "] {$level} {$message}\n"; file_put_contents($this->logFile, $line, FILE_APPEND); fwrite($level === 'ERROR' ? STDERR : STDOUT, $line); } } final class TruckwashEdgeAgent { private AgentConfig $config; private HttpJsonClient $http; private Logger $logger; private string $installDir; private string $runtimeDir; private string $statePath; private string $lastOperationSnapshotPath; private string $lastHeartbeatMarkerPath; private int $lastHeartbeatAt = 0; private string $agentInstanceId; public function __construct(string $configPath) { $this->config = AgentConfig::load($configPath); $this->http = new HttpJsonClient((string)$this->config->get('apiUrl')); $this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR); $this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime'; @mkdir($this->runtimeDir, 0777, true); $this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log'); $this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json'; $this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json'; $this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt'; $this->agentInstanceId = $this->ensureAgentInstanceId(); } public function run(): void { $this->logger->info('Truckwash PHP edge agent starting.'); $this->recoverPreviousOperationState(); while (true) { try { $this->ensureClaimed(); $this->heartbeat(); $processedManagementOperation = $this->processManagementOperation(); if (!$processedManagementOperation) { $this->processCommandQueue(); } } catch (Throwable $throwable) { $this->logger->error($throwable->getMessage()); sleep(2); } usleep(250000); } } private function ensureClaimed(): void { if ($this->config->get('gatewayId') && $this->config->get('agentToken')) { return; } $response = $this->http->post('/edge-agent/claim', [ 'token' => (string)$this->config->get('installToken'), 'hostname' => gethostname() ?: 'truckwash-edge', 'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'), 'metadata' => [ 'runtime' => 'php-cli', 'php_version' => PHP_VERSION, 'agent_instance_id' => $this->agentInstanceId, ], ]); $payload = $response['data'] ?? []; $gateway = $payload['gateway'] ?? []; $installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1'); $this->config->set('gatewayId', $gateway['id'] ?? null); $this->config->set('agentToken', $payload['agent_token'] ?? null); $this->config->set('installedVersion', $installedVersion); $this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion)); $this->config->set('agentInstanceId', $this->agentInstanceId); $this->config->save(); $this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.'); } private function heartbeat(bool $force = false, array $metadata = []): void { $interval = (int)$this->config->get('heartbeatIntervalSeconds', 15); if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) { return; } $gatewayId = (int)$this->config->get('gatewayId'); if ($gatewayId <= 0) { return; } $operationState = $this->readOperationState(); $this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [ 'agent_token' => (string)$this->config->get('agentToken'), 'status' => 'ONLINE', 'hostname' => gethostname() ?: 'truckwash-edge', 'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'), 'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')), 'metadata' => array_merge([ 'agent_instance_id' => $this->agentInstanceId, 'current_operation' => $operationState, 'system_metrics' => [ 'memory_usage_bytes' => memory_get_usage(true), 'memory_peak_bytes' => memory_get_peak_usage(true), 'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [], ], ], $metadata), ]); $this->lastHeartbeatAt = time(); file_put_contents($this->lastHeartbeatMarkerPath, json_encode([ 'gateway_id' => $gatewayId, 'at' => date('c'), 'agent_instance_id' => $this->agentInstanceId, ], JSON_UNESCAPED_SLASHES) . PHP_EOL); } private function processCommandQueue(): void { $gatewayId = (int)$this->config->get('gatewayId'); $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [ 'agent_token' => (string)$this->config->get('agentToken'), 'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20), ]); $command = $response['data'] ?? null; if (!is_array($command) || empty($command['id'])) { return; } $jobId = (int)$command['id']; $type = (string)($command['command_type'] ?? $command['commandType'] ?? ''); $request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : []; try { $result = match ($type) { 'DISCOVER_SHELLY' => $this->runDiscovery(), 'GET_RELAY_STATUS' => $this->readRelayStatus($request), 'SET_RELAY_STATE' => $this->switchRelay($request), default => throw new RuntimeException('Unsupported command type: ' . $type), }; $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [ 'agent_token' => (string)$this->config->get('agentToken'), 'ok' => true, 'result' => $result, ]); } catch (Throwable $throwable) { $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [ 'agent_token' => (string)$this->config->get('agentToken'), 'ok' => false, 'error' => $throwable->getMessage(), ]); } } private function processManagementOperation(): bool { $gatewayId = (int)$this->config->get('gatewayId'); $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [ 'agent_token' => (string)$this->config->get('agentToken'), 'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20), 'agent_instance_id' => $this->agentInstanceId, ]); $operation = $response['data'] ?? null; if (!is_array($operation) || empty($operation['id'])) { return false; } $operationId = (int)$operation['id']; $type = (string)($operation['type'] ?? ''); $request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : []; $this->persistOperationState([ 'gateway_id' => $gatewayId, 'operation_id' => $operationId, 'type' => $type, 'status' => 'IN_PROGRESS', 'stage' => 'claimed', 'progress' => 5, 'agent_instance_id' => $this->agentInstanceId, 'started_at' => date('c'), 'request' => $request, ]); $this->emitOperationProgress( $gatewayId, $operationId, 'OPERATION_AGENT_STARTED', 'PHP edge agent started processing the operation', 10, ['type' => $type, 'agent_instance_id' => $this->agentInstanceId], 'starting' ); try { $result = match ($type) { 'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request), 'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request), 'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request), default => throw new RuntimeException('Unsupported operation type: ' . $type), }; $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [ 'agent_token' => (string)$this->config->get('agentToken'), 'ok' => true, 'result' => $result, ]); $this->snapshotOperationState('COMPLETED', ['result' => $result]); $this->clearOperationState(); } catch (Throwable $throwable) { $errorCode = $this->classifyManagementError($throwable, $type); $this->postOperationEvent($gatewayId, $operationId, [ 'level' => 'ERROR', 'code' => $errorCode, 'message' => $throwable->getMessage(), 'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId], ]); $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [ 'agent_token' => (string)$this->config->get('agentToken'), 'ok' => false, 'error_code' => $errorCode, 'error_message' => $throwable->getMessage(), ]); $this->snapshotOperationState('FAILED', [ 'error_code' => $errorCode, 'error_message' => $throwable->getMessage(), ]); $this->clearOperationState(); } return true; } private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void { $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [ 'agent_token' => (string)$this->config->get('agentToken'), 'level' => (string)($payload['level'] ?? 'INFO'), 'code' => $payload['code'] ?? null, 'message' => (string)($payload['message'] ?? 'Operation event'), 'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [], ]); } private function emitOperationProgress( int $gatewayId, int $operationId, string $code, string $message, int $progress, array $context = [], ?string $stage = null ): void { $operationState = $this->readOperationState(); if ($operationState !== null) { $operationState['progress'] = max(0, min(100, $progress)); $operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running'); $operationState['updated_at'] = date('c'); $this->persistOperationState($operationState); } $this->heartbeat(true, [ 'current_operation' => $this->readOperationState(), ]); $this->postOperationEvent($gatewayId, $operationId, [ 'level' => 'INFO', 'code' => $code, 'message' => $message, 'context' => array_merge($context, [ 'progress' => max(0, min(100, $progress)), 'agent_instance_id' => $this->agentInstanceId, ]), ]); } private function runDiscovery(): array { return ['inventory' => []]; } private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array { $this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering'); $hostname = gethostname() ?: 'truckwash-edge'; $fallbackInventory = [[ 'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10), 'local_ip' => gethostbyname($hostname), 'model' => 'TruckWash Edge Gateway', 'channel_count' => 1, 'online' => true, 'capabilities' => [ 'local_discovery' => true, 'relay_commands' => true, 'gateway_management_v2' => true, ], 'metadata' => [ 'hostname' => $hostname, 'runtime' => 'php-cli', 'php_version' => PHP_VERSION, ], ]]; $inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== [] ? (array)$request['inventory'] : $fallbackInventory; $this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [ 'device_count' => count($inventory), ], 'finishing'); return ['inventory' => $inventory]; } private function runUpdate(int $gatewayId, int $operationId, array $request): array { $targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? '')); if ($targetVersion === '') { throw new RuntimeException('Update request is missing target version'); } $agentArtifactUrl = trim((string)($request['artifactUrl'] ?? '')); $serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? '')); $agentArtifactSha = trim((string)($request['artifactSha256'] ?? '')); $serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? '')); $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [ 'target_version' => $targetVersion, ], 'validating'); $changedFiles = []; if ($agentArtifactUrl !== '') { $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [ 'url' => $agentArtifactUrl, ], 'downloading-agent'); $changedFiles[] = $this->downloadToFileAtomic( $agentArtifactUrl, $this->installDir . DIRECTORY_SEPARATOR . 'agent.php', $agentArtifactSha ); } if ($serviceUnitUrl !== '') { $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [ 'url' => $serviceUnitUrl, ], 'downloading-service'); $changedFiles[] = $this->downloadToFileAtomic( $serviceUnitUrl, $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service', $serviceUnitSha ); } $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config'); $this->config->set('installedVersion', $targetVersion); $this->config->set('targetVersion', $targetVersion); $this->config->set('lastStagedUpdate', [ 'target_version' => $targetVersion, 'staged_at' => date('c'), 'changed_files' => $changedFiles, ]); $this->config->save(); $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [ 'changed_files' => $changedFiles, ], 'staged'); return [ 'installed_version' => $targetVersion, 'target_version' => $targetVersion, 'restart_required' => true, 'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'), 'changed_files' => $changedFiles, 'agent_instance_id' => $this->agentInstanceId, ]; } private function runUninstall(int $gatewayId, int $operationId, array $request): array { $this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall'); $manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json'; $manifest = [ 'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')), 'install_dir' => (string)($request['install_dir'] ?? $this->installDir), 'generated_at' => date('c'), 'agent_instance_id' => $this->agentInstanceId, ]; file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL); $this->config->set('pendingUninstall', $manifest); $this->config->save(); $this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [ 'manifest_path' => $manifestPath, ], 'uninstall-ready'); return [ 'uninstalled' => true, 'service_name' => $manifest['service_name'], 'install_dir' => $manifest['install_dir'], 'manual_cleanup_required' => true, 'cleanup_manifest_path' => $manifestPath, ]; } private function readRelayStatus(array $request): array { $localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? ''); $channel = (int)($request['channel'] ?? 0); return $this->fetchShellyState($localIp, $channel); } private function switchRelay(array $request): array { $localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? ''); $channel = (int)($request['channel'] ?? 0); $on = (bool)($request['on'] ?? false); $rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'); try { $this->http->getJson($rpcUrl, 8); } catch (Throwable) { $legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'); $this->http->getJson($legacyUrl, 8); } return $this->fetchShellyState($localIp, $channel); } private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array { $directory = dirname($path); if (!is_dir($directory)) { @mkdir($directory, 0777, true); } $temporaryPath = $path . '.download'; $backupPath = $path . '.bak'; $raw = $this->http->download($url, 60); file_put_contents($temporaryPath, $raw); if ($expectedSha256 !== '') { $actualSha = hash_file('sha256', $temporaryPath); if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) { @unlink($temporaryPath); throw new RuntimeException('Artifact checksum mismatch for ' . basename($path)); } } if (is_file($backupPath)) { @unlink($backupPath); } if (is_file($path) && !@rename($path, $backupPath)) { @unlink($temporaryPath); throw new RuntimeException('Could not create backup for ' . $path); } if (!@rename($temporaryPath, $path)) { if (is_file($backupPath)) { @rename($backupPath, $path); } @unlink($temporaryPath); throw new RuntimeException('Could not promote downloaded artifact for ' . $path); } return [ 'path' => $path, 'url' => $url, 'backup_path' => is_file($backupPath) ? $backupPath : null, 'sha256' => hash_file('sha256', $path), ]; } private function classifyManagementError(Throwable $throwable, string $type): string { $message = strtolower(trim($throwable->getMessage())); if (str_contains($message, 'token') || str_contains($message, 'credential')) { return 'EDGE_GATEWAY_INVALID_TOKEN'; } if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) { return 'EDGE_GATEWAY_OPERATION_TIMEOUT'; } if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) { return 'EDGE_GATEWAY_UNSUPPORTED_VERSION'; } return 'EDGE_GATEWAY_VALIDATION_FAILED'; } private function fetchShellyState(string $localIp, int $channel): array { if ($localIp === '') { throw new RuntimeException('Missing Shelly IP address'); } try { $payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8); return [ 'online' => true, 'on' => (bool)($payload['output'] ?? false), 'output' => (bool)($payload['output'] ?? false), 'raw' => $payload, ]; } catch (Throwable) { $payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8); return [ 'online' => true, 'on' => (bool)($payload['ison'] ?? false), 'output' => (bool)($payload['ison'] ?? false), 'raw' => $payload, ]; } } private function ensureAgentInstanceId(): string { $configured = trim((string)$this->config->get('agentInstanceId', '')); if ($configured !== '') { return substr($configured, 0, 128); } $generated = sprintf( '%s-%s', preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge', substr(bin2hex(random_bytes(6)), 0, 12) ); $this->config->set('agentInstanceId', $generated); $this->config->save(); return $generated; } private function recoverPreviousOperationState(): void { $state = $this->readOperationState(); if ($state === null) { return; } $this->logger->warning(sprintf( 'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.', (string)($state['operation_id'] ?? 'unknown'), (string)($state['type'] ?? 'unknown'), (string)($state['stage'] ?? 'unknown') )); } private function persistOperationState(array $state): void { file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } private function snapshotOperationState(string $status, array $extra = []): void { $state = $this->readOperationState() ?? []; $state['status'] = $status; $state['finished_at'] = date('c'); $state = array_merge($state, $extra); file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); } private function clearOperationState(): void { if (is_file($this->statePath)) { @unlink($this->statePath); } } private function readOperationState(): ?array { if (!is_file($this->statePath)) { return null; } $decoded = json_decode((string)file_get_contents($this->statePath), true); return is_array($decoded) ? $decoded : null; } } $configPath = null; foreach ($argv as $index => $argument) { if ($argument === '--config' && isset($argv[$index + 1])) { $configPath = $argv[$index + 1]; break; } } if (!is_string($configPath) || trim($configPath) === '') { fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n"); exit(1); } (new TruckwashEdgeAgent($configPath))->run();