>|null */ private ?array $shellyRelayOptionsCache = null; /** @var array> */ private static array $relayActionContextStack = []; public function __construct() { edge_gateway_schema_bootstrap::ensureTables(); } public static function withRelayActionContext(array $context, callable $callback): mixed { self::$relayActionContextStack[] = $context; try { return $callback(); } finally { array_pop(self::$relayActionContextStack); } } private static function configuredDefaultReleaseChannel(): string { try { $value = trim((string)(new edgegateway())->defaultReleaseChannel()); if ($value !== '') { return $value; } } catch (Exception $exception) { } return self::DEFAULT_RELEASE_CHANNEL; } private static function configuredDefaultUpdateWindow(): string { try { $value = trim((string)(new edgegateway())->defaultUpdateWindow()); if ($value !== '') { return $value; } } catch (Exception $exception) { } return self::DEFAULT_UPDATE_WINDOW; } public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array { $this->requireDepartment($departmentId); $token = bin2hex(random_bytes(24)); $expiresAt = $this->formatDateTime(time() + self::INSTALL_TOKEN_TTL_SECONDS); $claimToken = new edge_gateway_claim_tokens_o(); $claimTokenId = $claimToken->add_object([ 'department_id' => $departmentId, 'label' => $label, 'token_hash' => $this->hashToken($token), 'created_by' => $createdBy, 'expires_at' => $expiresAt, 'metadata_json' => [ 'install_session' => self::mergeInstallSessionUpdate([], [ 'status' => self::INSTALL_SESSION_STATUS_PENDING, 'step' => self::INSTALL_SESSION_STATUS_PENDING, 'message' => 'Installer command generated. Run it on the gateway host.', ], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS), ], ]); $claimToken->select($claimTokenId); $this->writeAudit( null, $departmentId, 'INSTALL_TOKEN_CREATED', $createdBy, ['claim_token_id' => $claimTokenId, 'label' => $label] ); return [ 'claim_token_id' => $claimTokenId, 'token' => $token, 'expires_at' => (string)$claimToken->expires_at->value(), 'install_command' => $this->buildInstallCommand($token), 'install_url' => $this->buildInstallScriptUrl($token), ]; } private function buildShellReadinessDiagnostics(edge_gateways_o $gateway): array { $gatewayId = (int)$gateway->id; $brokerUrl = $this->buildBrokerPublicUrl(); $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'); $presence = $this->readBrokerPresence($gatewayId); $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null; $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt); $connected = self::isBrokerPresenceConnected($presence); $configuredBrokerUrl = $this->configuredPublicBrokerUrl(); $derivedWarning = null; if ($configuredBrokerUrl === '') { $apiBaseUrl = rtrim($this->getApiBaseUrl(), '/'); $apiPath = (string)(parse_url($apiBaseUrl, PHP_URL_PATH) ?: ''); $apiHost = (string)(parse_url($apiBaseUrl, PHP_URL_HOST) ?: ''); if ($apiPath === '/api' && !in_array($apiHost, ['localhost', '127.0.0.1'], true)) { $derivedWarning = 'BROKER_PUBLIC_URL_DERIVED_WITH_API_PREFIX'; } } $diagnostics = [ 'ready' => false, 'reason_code' => 'BROKER_NOT_READY', 'message' => 'Gateway shell is not ready.', 'gateway_id' => $gatewayId, 'broker_url' => $brokerUrl, 'ws_url' => $wsUrl, 'public_broker_url_configured' => $configuredBrokerUrl !== '', 'broker_auth_mode' => $this->configuredBrokerAuthMode(), 'derived_warning' => $derivedWarning, 'broker_presence' => [ 'connected' => !empty($presence['connected']), 'connection_id' => isset($presence['connection_id']) ? (string)$presence['connection_id'] : null, 'last_seen_at' => $lastSeenAt, 'age_seconds' => $ageSeconds, 'disconnect_reason' => isset($presence['disconnect_reason']) ? (string)$presence['disconnect_reason'] : null, 'last_error' => isset($presence['last_error']) ? (string)$presence['last_error'] : null, ], ]; if ($brokerUrl === null || $wsUrl === null) { return array_merge($diagnostics, [ 'reason_code' => 'BROKER_NOT_CONFIGURED', 'message' => 'The public edge broker URL is not configured, so a gateway shell cannot be opened.', ]); } if ($presence === []) { return array_merge($diagnostics, [ 'reason_code' => 'BROKER_DISCONNECTED', 'message' => 'Gateway agent has not reported an active broker connection yet.', ]); } if (empty($presence['connected'])) { return array_merge($diagnostics, [ 'reason_code' => 'BROKER_DISCONNECTED', 'message' => 'Gateway agent is not connected to the edge broker.', ]); } if (!$connected) { return array_merge($diagnostics, [ 'reason_code' => 'BROKER_STALE', 'message' => 'Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell.', ]); } return array_merge($diagnostics, [ 'ready' => true, 'reason_code' => $derivedWarning ?: 'READY', 'message' => $derivedWarning === null ? 'Gateway shell broker path is ready.' : 'Gateway shell broker path is ready, but the broker URL was derived from the API URL.', ]); } /** * @throws Exception */ public function claimGateway(string $token, string $hostname, ?string $installedVersion = null, array $metadata = []): array { $claimToken = $this->requireClaimToken($token); if ($claimToken->used_at->value() !== null) { throw new Exception('Install token has already been used'); } $departmentId = (int)$claimToken->department_id->value(); $label = trim((string)($claimToken->label->value() ?? $hostname)); $label = $label !== '' ? $label : 'Department gateway'; $agentToken = bin2hex(random_bytes(32)); $gateway = new edge_gateways_o(); $gatewayId = $gateway->add_object([ 'department_id' => $departmentId, 'label' => $label, 'hostname' => trim($hostname) !== '' ? trim($hostname) : null, 'agent_token_hash' => $this->hashToken($agentToken), 'status' => self::STATUS_ONLINE, 'transport_mode' => self::TRANSPORT_MODE_GATEWAY, 'release_channel' => self::configuredDefaultReleaseChannel(), 'installed_version' => $installedVersion, 'target_version' => $installedVersion, 'last_heartbeat_at' => $this->now(), 'last_seen_ip' => $this->remoteIp(), 'discovery_status' => 'PENDING', 'is_primary' => 1, 'metadata_json' => array_merge($metadata, [ 'credentials_rotated_at' => $this->now(), 'agent_runtime' => 'compose-php', 'runtime_mode' => 'compose', 'update_window' => self::configuredDefaultUpdateWindow(), 'container_health' => [ 'overall_status' => self::STATUS_PENDING, 'services' => self::defaultGatewayServiceHealth(self::STATUS_PENDING), ], 'outbox_status' => [ 'depth' => 0, 'oldest_age_seconds' => 0, 'last_flushed_at' => null, 'pending_types' => [], ], 'rollback_status' => [ 'state' => 'NONE', 'reason' => null, 'at' => null, ], 'last_sync_at' => null, ]), ]); $claimToken->used_at->set($this->now()); $this->persistInstallSession($claimToken, [ 'status' => self::INSTALL_SESSION_STATUS_CLAIMED, 'step' => self::INSTALL_SESSION_STATUS_CLAIMED, 'message' => 'Gateway claimed successfully.', 'gateway_id' => $gatewayId, 'last_error' => null, 'diagnostics' => [], ]); $gateway->select($gatewayId); $this->setGatewayPrimaryState($gateway, true); $this->writeAudit( $gatewayId, $departmentId, 'GATEWAY_CLAIMED', null, ['hostname' => $hostname, 'installed_version' => $installedVersion] ); $gatewayPayload = $this->getGateway($gatewayId); edge_gateway_view_cache::syncGateway($gatewayPayload); return [ 'gateway' => $gatewayPayload, 'agent_token' => $agentToken, 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', 'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll', 'operations_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/next', 'operation_events_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/events', 'operation_complete_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/complete', 'broker_url' => $this->buildBrokerPublicUrl(), 'release_channel' => (string)$gateway->release_channel->value(), ]; } /** * @throws Exception */ public function authenticateGateway(int $gatewayId, string $plainToken): edge_gateways_o { $gateway = $this->requireGateway($gatewayId); if (!hash_equals((string)$gateway->agent_token_hash->value(), $this->hashToken($plainToken))) { throw new Exception('Invalid edge gateway token'); } return $gateway; } /** * @throws Exception */ public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $existingMetadata = (array)($gateway->metadata_json->value() ?? []); $payloadMetadata = (array)($payload['metadata'] ?? []); $metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata); $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->last_heartbeat_at->set($this->now()); $gateway->last_seen_ip->set($this->remoteIp()); $gateway->metadata_json->set($metadata); if (isset($payload['inventory']) && is_array($payload['inventory'])) { $this->syncDeviceInventory($gatewayId, $payload['inventory']); } $gatewayPayload = $this->getGateway($gatewayId); edge_gateway_view_cache::syncGateway($gatewayPayload); return $gatewayPayload; } private function mergeHeartbeatBrokerPresence(int $gatewayId, array $existingMetadata, array $payloadMetadata): array { $metadata = array_merge($existingMetadata, $payloadMetadata); if (!array_key_exists('broker_connected', $payloadMetadata)) { return $metadata; } $connected = (bool)$payloadMetadata['broker_connected']; $existingPresence = isset($existingMetadata['broker_presence']) && is_array($existingMetadata['broker_presence']) ? (array)$existingMetadata['broker_presence'] : []; $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata']) ? (array)$existingPresence['metadata'] : []; $now = $this->now(); $disconnectReason = isset($payloadMetadata['broker_disconnect_reason']) ? trim((string)$payloadMetadata['broker_disconnect_reason']) : ''; $lastError = isset($payloadMetadata['broker_last_error']) ? trim((string)$payloadMetadata['broker_last_error']) : ''; $presence = [ 'gateway_id' => $gatewayId, 'connected' => $connected, 'connection_id' => isset($existingPresence['connection_id']) && trim((string)$existingPresence['connection_id']) !== '' ? (string)$existingPresence['connection_id'] : null, 'last_seen_at' => $now, 'disconnect_reason' => $connected ? null : ($disconnectReason !== '' ? $disconnectReason : null), 'last_error' => $connected ? null : ($lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null)), 'metadata' => array_merge($presenceMetadata, array_filter([ 'agent_instance_id' => $payloadMetadata['agent_instance_id'] ?? null, 'broker_url' => $payloadMetadata['broker_url'] ?? null, ], static fn(mixed $value): bool => $value !== null && $value !== '')), ]; $metadata['broker_presence'] = $presence; $metadata['broker_connected'] = $connected; if ($connected) { $metadata['broker_connected_at'] = $metadata['broker_connected_at'] ?? $now; $metadata['broker_last_error'] = null; } else { $metadata['broker_disconnected_at'] = $now; $metadata['broker_last_error'] = $lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null); } $this->writeBrokerPresence($gatewayId, $presence); return $metadata; } /** * @return array> * @throws Exception */ public function listGateways(?int $departmentId = null, bool $includeDetail = true): array { $gatewayObject = new edge_gateways_o(); $rows = $departmentId === null ? $gatewayObject->getFieldsWhere(['deleted_at' => null], ['id']) : $gatewayObject->getFieldsWhere(['department_id' => $departmentId, 'deleted_at' => null], ['id']); $gateways = []; $gatewayIds = []; foreach ($rows as $row) { $gatewayId = (int)$row['id']; try { $gateway = $this->requireGateway($gatewayId); } catch (Exception $exception) { if ($exception->getMessage() === 'Edge gateway not found') { continue; } throw $exception; } $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail); $gatewayIds[] = $gatewayId; } if ($gatewayIds !== []) { $gateways = self::attachGatewayCollectionSummaries( $gateways, $this->aggregateInventoryUsageByGateway($gatewayIds), $this->aggregateBindingUsageByGateway($gatewayIds) ); } usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id'])); return $gateways; } /** * @param array> $gateways * @return array * @throws Exception */ public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array { $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false); return self::summarizeFleetUsageFromGatewayRows($fleet); } /** * @param array> $gateways * @return array */ public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array { $inventory = $inventoryUsage === [] ? self::aggregateInventoryUsageFromGatewayRows($gateways) : array_merge(self::emptyInventoryUsage(), $inventoryUsage); $bindings = $bindingUsage === [] ? self::aggregateBindingUsageFromGatewayRows($gateways) : array_merge(self::emptyBindingUsage(), $bindingUsage); $totalGateways = count($gateways); $departmentIds = []; $gatewayOnline = 0; $gatewayOffline = 0; $gatewayDegraded = 0; $gatewayDrifted = 0; $brokerConnected = 0; $activeOperations = 0; $pendingOperations = 0; $inProgressOperations = 0; $operationBacklog = 0; $commandBacklog = 0; $latencyValues = []; $cpuValues = []; $memoryValues = []; $diskValues = []; foreach ($gateways as $gateway) { $departmentId = (int)($gateway['department_id'] ?? 0); if ($departmentId > 0) { $departmentIds[$departmentId] = true; } $status = strtoupper((string)($gateway['status'] ?? self::STATUS_OFFLINE)); if ($status === self::STATUS_ONLINE) { $gatewayOnline += 1; } elseif ($status === self::STATUS_DEGRADED) { $gatewayDegraded += 1; } else { $gatewayOffline += 1; } if (!empty($gateway['version_drift']['is_drifted'])) { $gatewayDrifted += 1; } if (!empty($gateway['channel_status']['broker']['connected'])) { $brokerConnected += 1; } if (!empty($gateway['active_operation'])) { $activeOperations += 1; } $recentSummary = isset($gateway['recent_operations_summary']) && is_array($gateway['recent_operations_summary']) ? (array)$gateway['recent_operations_summary'] : []; $pendingOperations += (int)($recentSummary['pending'] ?? 0); $inProgressOperations += (int)($recentSummary['in_progress'] ?? 0); $backlog = isset($gateway['backlog_depth']) && is_array($gateway['backlog_depth']) ? (array)$gateway['backlog_depth'] : []; $operationBacklog += (int)($backlog['operations'] ?? 0); $commandBacklog += (int)($backlog['commands'] ?? 0); $metrics = isset($gateway['metadata']['system_metrics']) && is_array($gateway['metadata']['system_metrics']) ? (array)$gateway['metadata']['system_metrics'] : []; self::appendNumericMetric($latencyValues, $metrics['latency_ms'] ?? null); self::appendNumericMetric($cpuValues, $metrics['cpu_usage_pct'] ?? null); self::appendNumericMetric($memoryValues, $metrics['memory_usage_pct'] ?? null); self::appendNumericMetric($diskValues, $metrics['disk_usage_pct'] ?? null); } return [ 'gateways' => [ 'total' => $totalGateways, 'departments' => count($departmentIds), 'online' => $gatewayOnline, 'offline' => $gatewayOffline, 'degraded' => $gatewayDegraded, 'drifted' => $gatewayDrifted, 'broker_connected' => $brokerConnected, ], 'inventory' => $inventory, 'bindings' => $bindings, 'operations' => [ 'active' => $activeOperations, 'pending' => $pendingOperations, 'in_progress' => $inProgressOperations, 'backlog' => $operationBacklog, ], 'commands' => [ 'backlog' => $commandBacklog, ], 'system' => [ 'latency_ms_avg' => self::averageMetric($latencyValues), 'cpu_usage_pct_avg' => self::averageMetric($cpuValues), 'memory_usage_pct_avg' => self::averageMetric($memoryValues), 'disk_usage_pct_avg' => self::averageMetric($diskValues), ], ]; } /** * @param array> $gateways * @return array */ public static function summarizeFleetUsageFromGatewayRows(array $gateways): array { return self::summarizeFleetUsage( $gateways, self::aggregateInventoryUsageFromGatewayRows($gateways), self::aggregateBindingUsageFromGatewayRows($gateways) ); } /** * @param array $gateway * @return array */ public static function prepareGatewayForListCache(array $gateway, bool $includeDetail): array { $gateway = self::decorateGatewayUsageSummaries($gateway); if ($includeDetail) { return $gateway; } $gateway['inventory'] = []; $gateway['bindings'] = []; $gateway['recent_commands'] = []; $gateway['audit_logs'] = []; if (isset($gateway['operations']) && is_array($gateway['operations'])) { $gateway['operations'] = array_map(static function (mixed $operation): mixed { if (!is_array($operation)) { return $operation; } $operation['events'] = []; return $operation; }, array_slice($gateway['operations'], 0, 5)); } return $gateway; } /** * @throws Exception */ public function getGateway(int $gatewayId): array { $gateway = $this->requireGateway($gatewayId); return self::decorateGatewayUsageSummaries($this->buildGatewayPayload($gateway, true)); } /** * @throws Exception */ private function buildGatewayPayload(edge_gateways_o $gateway, bool $includeDetail): array { $data = $gateway->asArray(); $operations = new edge_gateway_operation_service($this); $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); $gatewayId = (int)$gateway->id; $this->expireTimedOutRelayStatusCommandJobs($gatewayId); if ($includeDetail) { $data['inventory'] = $this->listInventory($gatewayId); $data['bindings'] = $this->listBindings($gatewayId); $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); $data['operations'] = $operations->listOperations($gatewayId, 12, true); } else { $data['inventory'] = []; $data['bindings'] = []; $data['recent_commands'] = []; $data['audit_logs'] = []; $data['operations'] = $operations->listOperations($gatewayId, 5, false); } $data['active_operation'] = $operations->getActiveOperation($gatewayId, false); $data['recent_operations_summary'] = $operations->buildRecentOperationsSummary($gatewayId); $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id); return self::deriveGatewayRuntimeState($data); } /** * @throws Exception */ public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $previousLabel = (string)$gateway->label->value(); $previousIsPrimary = (bool)$gateway->is_primary->value(); $label = trim((string)($payload['label'] ?? $previousLabel)); if ($label === '') { throw new Exception('Gateway label is required'); } $isPrimary = (bool)($payload['is_primary'] ?? $previousIsPrimary); $gateway->label->set($label); if ($isPrimary) { $this->setGatewayPrimaryState($gateway, true); } elseif ($isPrimary !== $previousIsPrimary) { $this->setGatewayPrimaryState($gateway, false); } $this->writeAudit( $gatewayId, (int)$gateway->department_id->value(), 'GATEWAY_METADATA_UPDATED', $userId, [ 'label' => $label, 'previous_label' => $previousLabel, 'is_primary' => $isPrimary, 'previous_is_primary' => $previousIsPrimary, ] ); $gatewayPayload = $this->getGateway($gatewayId); edge_gateway_view_cache::syncGateway($gatewayPayload); return $gatewayPayload; } /** * @throws Exception */ public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array { if (!in_array($transportMode, [self::TRANSPORT_MODE_CLOUD, self::TRANSPORT_MODE_GATEWAY], true)) { throw new Exception('Invalid transport mode'); } $department = $this->requireDepartment($departmentId); $department->variables->set(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE, $transportMode); $this->writeAudit( null, $departmentId, 'DEPARTMENT_TRANSPORT_MODE_UPDATED', $userId, ['transport_mode' => $transportMode] ); edge_gateway_view_cache::clearAll(); return [ 'department_id' => $departmentId, 'transport_mode' => $transportMode, ]; } public function getDepartmentTransportMode(int $departmentId): string { $variables = (new department_variables_o())->selectDepartment($departmentId); $mode = $variables->getVariable(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE); if ($mode === self::TRANSPORT_MODE_GATEWAY) { return self::TRANSPORT_MODE_GATEWAY; } return self::TRANSPORT_MODE_CLOUD; } /** * @return array> * @throws Exception */ public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $departmentId = (int)$gateway->department_id->value(); $incomingRelayIds = []; foreach ($bindings as $binding) { $relayId = trim((string)($binding['relay_id'] ?? '')); $deviceId = trim((string)($binding['device_id'] ?? '')); if ($relayId === '' || $deviceId === '') { throw new Exception('Each relay binding must contain relay_id and device_id'); } $bindingMetadata = $this->normalizeRelayBindingMetadata((array)($binding['metadata'] ?? []), $binding); $incomingRelayIds[] = $relayId; $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'relay_id' => $relayId, 'deleted_at' => null, ], ['id']); if ($existing !== []) { $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existing[0]['id']); $existingDeviceId = trim((string)$bindingObject->device_id->value()); $localIp = $this->resolveRelayBindingLocalIp( $gatewayId, $binding, $deviceId, $existingDeviceId === $deviceId ? $bindingObject->local_ip->value() : null ); $bindingObject->device_id->set($deviceId); $bindingObject->local_ip->set($localIp); $bindingObject->channel->set((int)($binding['channel'] ?? 0)); $bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL')); $bindingObject->approved_by->set($userId); $bindingObject->approved_at->set($this->now()); $bindingObject->metadata_json->set($bindingMetadata); continue; } $localIp = $this->resolveRelayBindingLocalIp($gatewayId, $binding, $deviceId); (new edge_gateway_relay_bindings_o())->add_object([ 'gateway_id' => $gatewayId, 'department_id' => $departmentId, 'relay_id' => $relayId, 'device_id' => $deviceId, 'local_ip' => $localIp, 'channel' => (int)($binding['channel'] ?? 0), 'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'), 'approved_by' => $userId, 'approved_at' => $this->now(), 'metadata_json' => $bindingMetadata, ]); } foreach ($this->listBindings($gatewayId) as $existingBinding) { if (in_array((string)$existingBinding['relay_id'], $incomingRelayIds, true)) { continue; } $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existingBinding['id']); $bindingObject->deleted_at->set($this->now()); } $this->writeAudit( $gatewayId, $departmentId, 'RELAY_BINDINGS_UPDATED', $userId, ['binding_count' => count($bindings)] ); edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); return $this->listBindings($gatewayId); } /** * @throws Exception */ public function queueDiscovery(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $gateway->discovery_status->set('PENDING'); $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId, [ 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), ]); $gatewayPayload = $this->getGateway($gatewayId); edge_gateway_view_cache::syncGateway($gatewayPayload); return $gatewayPayload; } /** * @throws Exception */ public function deleteGateway(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $departmentId = (int)$gateway->department_id->value(); $label = (string)$gateway->label->value(); if ((bool)$gateway->is_primary->value()) { $replacement = $this->findAlternateGatewayForDepartment($departmentId, $gatewayId); if ($replacement !== null) { $replacement->is_primary->set(true); } } $this->softDeleteGatewayRelations($gatewayId); $gateway->deleted_at->set($this->now()); $this->writeAudit( $gatewayId, $departmentId, 'GATEWAY_DELETED', $userId, ['label' => $label] ); edge_gateway_view_cache::removeGateway($gatewayId, $departmentId); return [ 'deleted' => true, 'gateway_id' => $gatewayId, 'department_id' => $departmentId, ]; } public function deleteOrphanedGatewaysForDepartment(int $departmentId): void { if ($departmentId <= 0 || $this->departmentExists($departmentId)) { return; } $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); foreach ($rows as $row) { $gatewayId = (int)($row['id'] ?? 0); if ($gatewayId <= 0) { continue; } try { $gateway = (new edge_gateways_o())->select($gatewayId); if ($gateway->exists()) { $this->softDeleteOrphanedGateway($gateway); } } catch (\Throwable) { edge_gateway_view_cache::removeGateway($gatewayId, $departmentId); } } } /** * @throws Exception */ public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $deadline = microtime(true) + max(0, $waitSeconds); do { $job = $this->claimNextCommandJob($gateway); if ($job !== null) { return $this->formatAgentCommandJob($job, $gateway); } if (microtime(true) >= $deadline) { break; } usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); } while (true); return null; } /** * @throws Exception */ public function submitCommandResult( int $gatewayId, int $jobId, string $plainToken, bool $ok, array $payload = [], ?string $error = null ): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $job = (new edge_gateway_command_jobs_o())->select($jobId); if (!$job->exists()) { throw new Exception('Edge gateway command job not found'); } if ((int)$job->gateway_id->value() !== (int)$gateway->id) { throw new Exception('Edge gateway command job does not belong to this gateway'); } $status = (string)$job->status->value(); if (in_array($status, ['COMPLETED', 'FAILED', 'TIMED_OUT'], true)) { return [ 'acknowledged' => true, 'job' => $job->asArray(), ]; } $errorMessage = $ok ? null : trim((string)$error); if (!$ok && $errorMessage === '') { $errorMessage = 'Edge gateway command failed'; } $this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway); edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); return [ 'acknowledged' => true, 'job' => $job->asArray(), ]; } /** * @throws Exception */ public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array { $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'department_id' => $departmentId, 'gateway_id' => (int)$gateway->id, 'relay_id' => $logicalRelayId, 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('No edge gateway relay binding found for relay ' . $logicalRelayId); } return (new edge_gateway_relay_bindings_o())->select((int)$rows[0]['id'])->asArray(); } /** * @throws Exception */ public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array { return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, false, $actionContext); } /** * @throws Exception */ public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array { return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true, $actionContext); } /** * @throws Exception */ private function dispatchRelayStatusWithOptions( int $departmentId, string $logicalRelayId, bool $requireFastLocalPath = false, array $actionContext = [] ): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $gateway = $this->requireGateway((int)$binding['gateway_id']); $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); if ($requireFastLocalPath) { $resolution = $this->forceLocalRelayExecutionPlan($resolution); } $actionContext = $this->normalizeRelayActionContext($actionContext); if (($resolution['execution_path'] ?? 'local') === 'cloud') { return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution, null, $actionContext); } $statusRequest = [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], ]; $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding); if ($deviceGeneration !== null) { $statusRequest['deviceGeneration'] = $deviceGeneration; $statusRequest['device_generation'] = $deviceGeneration; } $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, $this->resolveRelayRequestedBy($actionContext), [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, 'require_fast_path' => $requireFastLocalPath, ]); $dispatchLog = [ 'action' => 'STATUS', 'handler' => 'local', 'relay_id' => $logicalRelayId, 'signal' => $this->buildRelayCommandSignal($job, $statusRequest), 'action_context' => $actionContext, ]; try { $result = $this->dispatchGatewayCommand($gateway, $job); return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); } catch (Exception $exception) { $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, $binding, $resolution, null, $exception, null, $actionContext ); } } /** * @throws Exception */ public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, false, null, $actionContext); } /** * @throws Exception */ public function dispatchRelaySwitchWithTimer( int $departmentId, string $logicalRelayId, bool $on, ?int $toggleAfterSeconds, array $actionContext = [] ): array { return $this->dispatchRelaySwitchWithOptions( $departmentId, $logicalRelayId, $on, false, $this->normalizeRelayToggleAfter($toggleAfterSeconds), $actionContext ); } /** * @throws Exception */ public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true, null, $actionContext); } /** * @throws Exception */ public function dispatchRelaySwitchLocalOnlyWithTimer( int $departmentId, string $logicalRelayId, bool $on, ?int $toggleAfterSeconds, array $actionContext = [] ): array { return $this->dispatchRelaySwitchWithOptions( $departmentId, $logicalRelayId, $on, true, $this->normalizeRelayToggleAfter($toggleAfterSeconds), $actionContext ); } /** * @throws Exception */ private function dispatchRelaySwitchWithOptions( int $departmentId, string $logicalRelayId, bool $on, bool $requireFastLocalPath = false, ?int $toggleAfterSeconds = null, array $actionContext = [] ): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $gateway = $this->requireGateway((int)$binding['gateway_id']); $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); if ($requireFastLocalPath) { $resolution = $this->forceLocalRelayExecutionPlan($resolution); } $actionContext = $this->normalizeRelayActionContext($actionContext); if (($resolution['execution_path'] ?? 'local') === 'cloud') { return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution, $toggleAfterSeconds, $actionContext); } $request = [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], 'on' => $on, ]; $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding); if ($deviceGeneration !== null) { $request['deviceGeneration'] = $deviceGeneration; $request['device_generation'] = $deviceGeneration; } if ($toggleAfterSeconds !== null) { $request['toggleAfter'] = $toggleAfterSeconds; $request['toggle_after'] = $toggleAfterSeconds; } $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', $request, $this->resolveRelayRequestedBy($actionContext), [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, 'require_fast_path' => $requireFastLocalPath, ]); $dispatchLog = [ 'action' => 'SWITCH', 'handler' => 'local', 'relay_id' => $logicalRelayId, 'target_on' => $on, 'toggle_after_seconds' => $toggleAfterSeconds, 'signal' => $this->buildRelayCommandSignal($job, $request), 'action_context' => $actionContext, ]; try { $result = $this->dispatchGatewayCommand($gateway, $job); return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); } catch (Exception $exception) { $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, $binding, $resolution, $on, $exception, $toggleAfterSeconds, $actionContext ); } } /** * @return array> */ public function listBindings(int $gatewayId): array { return $this->listRecentObjects(new edge_gateway_relay_bindings_o(), [ 'gateway_id' => $gatewayId, 'deleted_at' => null, ], 100); } /** * @return array> */ public function listInventory(int $gatewayId): array { return $this->listRecentObjects(new edge_gateway_device_inventory_o(), [ 'gateway_id' => $gatewayId, 'deleted_at' => null, ], 100); } public function buildInstallCommand(string $plainToken): string { return 'curl -fsSL "' . $this->buildInstallScriptUrl($plainToken) . '" | sudo bash'; } public function buildInstallTokenVerifyUrl(string $plainToken): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/verify?token=' . urlencode($plainToken); } public function buildInstallScriptUrl(string $plainToken): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install.sh?token=' . urlencode($plainToken); } public function buildInstallScript(string $plainToken): string { $configJson = json_encode([ 'apiUrl' => $this->getApiBaseUrl(), 'brokerUrl' => $this->buildBrokerPublicUrl(), 'installToken' => $plainToken, 'gatewayId' => null, 'agentToken' => null, 'installDir' => self::DEFAULT_INSTALL_DIR, 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, 'runtimeMode' => 'compose', 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, 'lanWorkerArtifactName' => self::DEFAULT_LAN_WORKER_ARTIFACT, 'autoUpdaterArtifactName' => self::DEFAULT_AUTO_UPDATER_ARTIFACT, 'edgeAgentDockerfileName' => self::DEFAULT_EDGE_AGENT_DOCKERFILE, 'lanWorkerDockerfileName' => self::DEFAULT_LAN_WORKER_DOCKERFILE, 'autoUpdaterDockerfileName' => self::DEFAULT_AUTO_UPDATER_DOCKERFILE, 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, 'updateWindow' => self::configuredDefaultUpdateWindow(), 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, 'heartbeatIntervalSeconds' => 15, 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, ], JSON_UNESCAPED_SLASHES); $script = <<<'BASH' #!/usr/bin/env bash set -Eeuo pipefail INSTALL_DIR=/opt/truckwash-edge-agent RUNTIME_DIR="$INSTALL_DIR/runtime" CONFIG_PATH="$INSTALL_DIR/config.json" CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json" HEARTBEAT_MARKER_PATH="$RUNTIME_DIR/last-heartbeat-ok.txt" STACK_SERVICE_PATH="/etc/systemd/system/truckwash-edge-gateway-stack.service" INSTALL_TOKEN="__INSTALL_TOKEN__" INSTALL_STATUS_URL="__STATUS_URL__" CURRENT_STEP="Preparing installer" CURRENT_STEP_CODE="PENDING" CURRENT_METHOD="" CURRENT_URL="" INSTALL_STARTED_AT="$(date +%s)" REUSE_EXISTING_CREDENTIALS=0 DIAGNOSTIC_NAMES=() DIAGNOSTIC_OUTPUTS=() json_escape() { local value="${1:-}" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//$'\n'/\\n}" value="${value//$'\r'/\\r}" value="${value//$'\t'/\\t}" printf '%s' "$value" } trim_diagnostic_output() { printf '%s' "${1:-}" | awk 'NR <= 80 { print } NR == 81 { print "..."; exit }' | head -c 4000 } append_diagnostic() { local name="$1" local output="$2" if [ -z "$name" ] || [ -z "$output" ]; then return 0 fi DIAGNOSTIC_NAMES+=("$name") DIAGNOSTIC_OUTPUTS+=("$output") if [ "${#DIAGNOSTIC_NAMES[@]}" -gt 6 ]; then DIAGNOSTIC_NAMES=("${DIAGNOSTIC_NAMES[@]: -6}") DIAGNOSTIC_OUTPUTS=("${DIAGNOSTIC_OUTPUTS[@]: -6}") fi } emit_diagnostic_json() { local json="[" local index for index in "${!DIAGNOSTIC_NAMES[@]}"; do if [ "$index" -gt 0 ]; then json="${json}," fi json="${json}{\"name\":\"$(json_escape "${DIAGNOSTIC_NAMES[$index]}")\",\"output\":\"$(json_escape "${DIAGNOSTIC_OUTPUTS[$index]}")\"}" done json="${json}]" printf '%s' "$json" } capture_command_diagnostic() { local name="$1" shift local output="" set +e output="$("$@" 2>&1)" set -e output="$(trim_diagnostic_output "$output")" if [ -n "$output" ]; then append_diagnostic "$name" "$output" fi } report_install_status() { local status="$1" local step="$2" local message="$3" local diagnostics_json="${4:-[]}" local gateway_id="${5:-}" local payload payload="{\"token\":\"$(json_escape "$INSTALL_TOKEN")\",\"status\":\"$(json_escape "$status")\",\"step\":\"$(json_escape "$step")\",\"message\":\"$(json_escape "$message")\",\"diagnostics\":${diagnostics_json:-[]}" if [ -n "${gateway_id:-}" ] && [ "$gateway_id" -gt 0 ] 2>/dev/null; then payload="${payload},\"gateway_id\":${gateway_id}" fi payload="${payload}}" set +e curl -sS -X POST -H "Content-Type: application/json" --data-binary "$payload" "$INSTALL_STATUS_URL" >/dev/null 2>&1 set -e } begin_install_phase() { CURRENT_STEP_CODE="$1" CURRENT_STEP="$2" CURRENT_METHOD="" CURRENT_URL="" report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP" } log_info() { printf '[truckwash-edge-agent] %s\n' "$1" } log_error() { printf '[truckwash-edge-agent] ERROR: %s\n' "$1" >&2 } collect_install_diagnostics() { DIAGNOSTIC_NAMES=() DIAGNOSTIC_OUTPUTS=() if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then append_diagnostic "Last request" "${CURRENT_METHOD} ${CURRENT_URL}" fi if command -v systemctl >/dev/null 2>&1; then capture_command_diagnostic "systemctl status" systemctl status --no-pager truckwash-edge-gateway-stack.service fi if command -v journalctl >/dev/null 2>&1; then capture_command_diagnostic "journalctl" journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager fi if command -v docker >/dev/null 2>&1; then capture_command_diagnostic "docker ps" docker ps --format '{{.Names}} {{.Status}}' if [ -f "$INSTALL_DIR/docker-compose.gateway.yml" ]; then if docker compose version >/dev/null 2>&1; then capture_command_diagnostic "docker compose ps" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps capture_command_diagnostic "docker compose logs" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --tail=80 elif command -v docker-compose >/dev/null 2>&1; then capture_command_diagnostic "docker-compose ps" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps capture_command_diagnostic "docker-compose logs" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --tail=80 fi fi fi emit_diagnostic_json } on_error() { local exit_code=$? local failure_message="Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}" local diagnostics_json local gateway_id log_error "$failure_message" if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then log_error "Last request: ${CURRENT_METHOD} ${CURRENT_URL}" fi diagnostics_json="$(collect_install_diagnostics)" gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId 2>/dev/null || true)" report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id" exit "$exit_code" } trap on_error ERR run_step() { local description="$1" shift CURRENT_STEP="$description" CURRENT_METHOD="" CURRENT_URL="" log_info "$description" "$@" } fetch_http() { local description="$1" local url="$2" local output_path="${3:-}" local body_path="$output_path" local headers_path local status="" local curl_exit=0 local preview="" local cleanup_body=0 if [ -z "$body_path" ]; then body_path="$(mktemp)" cleanup_body=1 fi headers_path="$(mktemp)" CURRENT_STEP="$description" CURRENT_METHOD="GET" CURRENT_URL="$url" log_info "${description}: GET ${url}" set +e status="$(curl -sS -L -D "$headers_path" -o "$body_path" -w '%{http_code}' "$url")" curl_exit=$? set -e if [ "$curl_exit" -ne 0 ]; then log_error "${description} request failed before a successful HTTP response was received." log_error "Request: GET ${url}" log_error "curl exit code: ${curl_exit}" if [ -s "$headers_path" ]; then log_error "Response headers:" sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 fi if [ -s "$body_path" ]; then preview="$(head -c 400 "$body_path" || true)" if [ -n "$preview" ]; then log_error "Response body preview (first 400 bytes):" printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 fi fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" return "$curl_exit" fi if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then log_error "${description} returned HTTP ${status}." log_error "Request: GET ${url}" if [ -s "$headers_path" ]; then log_error "Response headers:" sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 fi if [ -s "$body_path" ]; then preview="$(head -c 400 "$body_path" || true)" if [ -n "$preview" ]; then log_error "Response body preview (first 400 bytes):" printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 fi fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" return 1 fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" } config_has_claimed_gateway() { local config_path="$1" php -r ' $path = $argv[1]; if (!is_file($path)) { exit(1); } $decoded = json_decode((string)file_get_contents($path), true); if (!is_array($decoded)) { exit(1); } $gatewayId = isset($decoded["gatewayId"]) ? (int)$decoded["gatewayId"] : 0; $agentToken = isset($decoded["agentToken"]) ? trim((string)$decoded["agentToken"]) : ""; exit($gatewayId > 0 && $agentToken !== "" ? 0 : 1); ' "$config_path" } read_config_value() { local config_path="$1" local key="$2" php -r ' $path = $argv[1]; $key = $argv[2]; if (!is_file($path)) { exit(0); } $decoded = json_decode((string)file_get_contents($path), true); if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null) { exit(0); } $value = $decoded[$key]; if (is_array($value) || is_object($value)) { echo json_encode($value, JSON_UNESCAPED_SLASHES); exit(0); } echo (string)$value; ' "$config_path" "$key" } merge_agent_config() { local template_path="$1" local config_path="$2" php -r ' $templatePath = $argv[1]; $configPath = $argv[2]; $template = json_decode((string)file_get_contents($templatePath), true); if (!is_array($template)) { fwrite(STDERR, "Invalid edge agent config template.\n"); exit(1); } $existing = []; if (is_file($configPath)) { $decoded = json_decode((string)file_get_contents($configPath), true); if (is_array($decoded)) { $existing = $decoded; } } foreach (["gatewayId", "agentToken", "agentInstanceId", "installedVersion", "targetVersion", "lastStagedUpdate"] as $key) { if (array_key_exists($key, $existing) && $existing[$key] !== null && $existing[$key] !== "") { $template[$key] = $existing[$key]; } } file_put_contents($configPath, json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); ' "$template_path" "$config_path" } heartbeat_marker_is_fresh() { local heartbeat_path="$1" local minimum_epoch="$2" if [ ! -f "$heartbeat_path" ]; then return 1 fi local modified_epoch modified_epoch="$(stat -c %Y "$heartbeat_path" 2>/dev/null || echo 0)" [ "${modified_epoch:-0}" -ge "$minimum_epoch" ] } print_service_diagnostics() { log_error "truckwash-edge-gateway-stack.service did not complete installation verification." log_error "systemctl status --no-pager truckwash-edge-gateway-stack.service" systemctl status --no-pager truckwash-edge-gateway-stack.service || true log_error "journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager" journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true log_error "docker ps --format '{{.Names}} {{.Status}}'" docker ps --format '{{.Names}} {{.Status}}' || true } resolve_compose_command() { if docker compose version >/dev/null 2>&1; then echo "docker compose" return 0 fi if command -v docker-compose >/dev/null 2>&1; then echo "docker-compose" return 0 fi return 1 } apt_package_exists() { local package_name="$1" apt-cache show "$package_name" 2>/dev/null | grep -q '^Package: ' } install_compose_runtime() { if resolve_compose_command >/dev/null 2>&1; then return 0 fi if apt_package_exists docker-compose-plugin; then log_info "Installing Docker Compose package docker-compose-plugin" apt-get install -y docker-compose-plugin elif apt_package_exists docker-compose; then log_info "Installing Docker Compose package docker-compose" apt-get install -y docker-compose else if apt-get install -y docker-compose-plugin; then : elif apt-get install -y docker-compose; then : else echo "Unable to install Docker Compose using docker-compose-plugin or docker-compose." >&2 return 1 fi fi if ! resolve_compose_command >/dev/null 2>&1; then echo "Docker Compose command is unavailable after installation." >&2 return 1 fi } wait_for_gateway_claim() { local config_path="$1" local heartbeat_path="$2" local minimum_epoch="$3" local timeout_seconds="${4:-30}" local elapsed=0 while [ "$elapsed" -lt "$timeout_seconds" ]; do if config_has_claimed_gateway "$config_path" && heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done log_error "Gateway claim did not complete within ${timeout_seconds}s." print_service_diagnostics return 1 } wait_for_post_restart_heartbeat() { local heartbeat_path="$1" local minimum_epoch="$2" local timeout_seconds="${3:-30}" local elapsed=0 while [ "$elapsed" -lt "$timeout_seconds" ]; do if heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done log_error "Gateway heartbeat was not observed within ${timeout_seconds}s after reinstall." print_service_diagnostics return 1 } begin_install_phase "VERIFY_TOKEN" "Verifying install token" fetch_http "Verify install token" "__VERIFY_URL__" begin_install_phase "INSTALL_PACKAGES" "Installing runtime dependencies" run_step "Creating install directory" mkdir -p "$INSTALL_DIR" "$RUNTIME_DIR" "$RUNTIME_DIR/backups" export DEBIAN_FRONTEND=noninteractive run_step "Updating package lists" apt-get update run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3 run_step "Installing Docker Compose runtime" install_compose_runtime begin_install_phase "DOWNLOAD_ARTIFACTS" "Downloading edge gateway artifacts" fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php" fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php" fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php" fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml" fetch_http "Download edge-agent Dockerfile" "__EDGE_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.edge-agent" fetch_http "Download lan-worker Dockerfile" "__WORKER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.lan-worker" fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater" fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh" fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service" fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service" if config_has_claimed_gateway "$CONFIG_PATH"; then REUSE_EXISTING_CREDENTIALS=1 log_info "Existing claimed gateway detected; reinstall will reuse saved gateway credentials." fi begin_install_phase "WRITE_CONFIG" "Writing gateway configuration" cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON' __CONFIG_JSON__ EOF_JSON run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH" rm -f "$CONFIG_TEMPLATE_PATH" begin_install_phase "START_STACK" "Starting edge gateway stack" run_step "Installing systemd stack definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-gateway-stack.service" "$STACK_SERVICE_PATH" run_step "Setting executable permissions" chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh" run_step "Ensuring Docker is enabled" systemctl enable docker run_step "Starting Docker" systemctl restart docker run_step "Checking Docker Compose availability" resolve_compose_command >/dev/null run_step "Reloading systemd" systemctl daemon-reload run_step "Enabling truckwash-edge-gateway-stack.service" systemctl enable truckwash-edge-gateway-stack.service run_step "Restarting truckwash-edge-gateway-stack.service" systemctl restart truckwash-edge-gateway-stack.service run_step "Verifying truckwash-edge-gateway-stack.service is active" systemctl is-active --quiet truckwash-edge-gateway-stack.service begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim" if [ "$REUSE_EXISTING_CREDENTIALS" -eq 1 ]; then run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180 claimed_gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId)" report_install_status "CLAIMED" "CLAIMED" "Gateway reconnected using preserved credentials." "[]" "$claimed_gateway_id" log_info "Reinstall reused gateway ${claimed_gateway_id}." else run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180 claimed_gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId)" report_install_status "CLAIMED" "CLAIMED" "Gateway claim completed successfully." "[]" "$claimed_gateway_id" log_info "Gateway claim completed for gateway ${claimed_gateway_id}." fi echo 'TruckWash edge gateway stack installed.' BASH; return strtr($script, [ '__INSTALL_TOKEN__' => $plainToken, '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken), '__STATUS_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/status', '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'), '__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT), '__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT), '__COMPOSE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE), '__EDGE_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE), '__WORKER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE), '__AUTO_UPDATER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), '__LAUNCHER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME), '__STACK_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME), '__LEGACY_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), '__CONFIG_JSON__' => (string)$configJson, ]); } /** * @throws Exception */ public function buildUpdateOperationRequest(string $targetVersion, string $releaseChannel): array { return array_merge( $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), [ 'target_version' => $targetVersion, 'release_channel' => $releaseChannel, ] ); } /** * @throws Exception */ public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $newToken = bin2hex(random_bytes(32)); $gateway->agent_token_hash->set($this->hashToken($newToken)); $metadata = (array)($gateway->metadata_json->value() ?? []); $metadata['credentials_rotated_at'] = $this->now(); $metadata['credential_rotation_requested_by'] = $userId; $gateway->metadata_json->set($metadata); $payload = [ 'apiUrl' => $this->getApiBaseUrl(), 'brokerUrl' => $this->buildBrokerPublicUrl(), 'gatewayId' => (int)$gateway->id, 'agentToken' => $newToken, 'installDir' => self::DEFAULT_INSTALL_DIR, 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, 'updateWindow' => (string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow()), 'runtimeMode' => (string)($metadata['runtime_mode'] ?? 'compose'), 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, 'heartbeatIntervalSeconds' => 15, 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, 'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(), ]; $this->writeAudit( (int)$gateway->id, (int)$gateway->department_id->value(), 'GATEWAY_CREDENTIALS_ROTATED', $userId, [ 'service_name' => self::DEFAULT_AGENT_SERVICE_NAME, 'stack_service_name' => self::DEFAULT_STACK_SERVICE_NAME, ] ); edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); return [ 'gateway_id' => (int)$gateway->id, 'rotated_at' => (string)$metadata['credentials_rotated_at'], 'agent_token' => $newToken, 'config' => $payload, 'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), 'restart_instructions' => [ 'sudo systemctl restart ' . self::DEFAULT_STACK_SERVICE_NAME, 'sudo systemctl status ' . self::DEFAULT_STACK_SERVICE_NAME . ' --no-pager', 'cd ' . self::DEFAULT_INSTALL_DIR . ' && sudo ./gateway-launcher.sh reconcile', ], ]; } public function syncGatewayInventory(int $gatewayId, array $inventory): void { $this->syncDeviceInventory($gatewayId, $inventory); } public function logGatewayAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void { $this->writeAudit($gatewayId, $departmentId, $action, $userId, $context); } /** * @throws Exception */ public function buildGatewayTasksPage(int $gatewayId): array { $gateway = $this->getGateway($gatewayId); $operations = new edge_gateway_operation_service($this); return [ 'gateway' => $gateway, 'active_operation' => $operations->getActiveOperation($gatewayId, true), 'operations' => $operations->listOperations($gatewayId, 20, true), 'recent_commands' => $this->listRecentObjects( new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null], 20 ), 'recent_operations_summary' => $operations->buildRecentOperationsSummary($gatewayId), ]; } /** * @throws Exception */ public function buildGatewayLogsPage(int $gatewayId, int $limit = 120): array { $gateway = $this->getGateway($gatewayId); $operations = (new edge_gateway_operation_service($this))->listOperations($gatewayId, 20, true); $auditLogs = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId], $limit); $liveLogs = $this->listRecentObjects( new edge_gateway_log_entries_o(), ['gateway_id' => $gatewayId], $limit ); $shellSessions = $this->listRecentObjects( new edge_gateway_shell_sessions_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null], 12 ); $relayLogs = []; $timeline = []; foreach ($auditLogs as $auditLog) { $timeline[] = [ 'type' => 'audit', 'level' => (string)($auditLog['severity'] ?? 'INFO'), 'message' => (string)($auditLog['action'] ?? 'AUDIT_EVENT'), 'created_at' => (string)($auditLog['created_at'] ?? ''), 'entry' => $auditLog, ]; } foreach ($liveLogs as $logEntry) { $type = strtolower(trim((string)($logEntry['stream'] ?? ''))) === 'relay' ? 'relay' : 'log'; if ($type === 'relay') { $relayLogs[] = $logEntry; } $timeline[] = [ 'type' => $type, 'level' => (string)($logEntry['level'] ?? 'INFO'), 'message' => (string)($logEntry['message'] ?? ''), 'created_at' => (string)($logEntry['created_at'] ?? ''), 'entry' => $logEntry, ]; } foreach ($operations as $operation) { foreach ((array)($operation['events'] ?? []) as $event) { if (!is_array($event)) { continue; } $timeline[] = [ 'type' => 'operation_event', 'level' => (string)($event['level'] ?? 'INFO'), 'message' => (string)($event['message'] ?? ''), 'created_at' => (string)($event['created_at'] ?? ''), 'entry' => array_merge($event, [ 'operation_id' => $operation['id'] ?? null, 'operation_type' => $operation['type'] ?? null, ]), ]; } } usort( $timeline, static fn(array $left, array $right): int => strcmp( (string)($right['created_at'] ?? ''), (string)($left['created_at'] ?? '') ) ); return [ 'gateway' => $gateway, 'timeline' => array_slice($timeline, 0, max(20, $limit)), 'audit_logs' => $auditLogs, 'log_entries' => $liveLogs, 'relay_logs' => $relayLogs, 'shell_sessions' => $shellSessions, ]; } /** * @throws Exception */ public function buildGatewayStatisticsPage(int $gatewayId): array { $gateway = $this->getGateway($gatewayId); $fleetUsage = $this->buildFleetUsageStatistics((int)$gateway['department_id'], [$gateway]); return [ 'gateway' => $gateway, 'fleet_usage' => $fleetUsage, 'channel_status' => (array)($gateway['channel_status'] ?? []), 'transport_health' => (array)($gateway['transport_health'] ?? []), 'backlog_depth' => (array)($gateway['backlog_depth'] ?? []), 'container_health' => (array)($gateway['container_health'] ?? []), 'system_metrics' => (array)($gateway['metadata']['system_metrics'] ?? []), 'version_drift' => (array)($gateway['version_drift'] ?? []), ]; } /** * @throws Exception */ public function validateGatewayAgentForBroker(int $gatewayId, string $plainToken): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); return [ 'id' => (int)$gateway->id, 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'label' => (string)$gateway->label->value(), 'broker_url' => $this->buildBrokerPublicUrl(), ]; } /** * @throws Exception */ public function createBrowserStreamSession(int $gatewayId, ?int $userId, array $scopes = []): array { $gateway = $this->requireGateway($gatewayId); $scopes = array_values(array_unique(array_filter(array_map( static fn(mixed $scope): string => strtolower(trim((string)$scope)), $scopes )))); if ($scopes === []) { $scopes = ['overview', 'tasks', 'logs', 'statistics']; } $expiresAt = time() + self::BROWSER_STREAM_TOKEN_TTL_SECONDS; $token = $this->buildSignedBrokerToken([ 'session_type' => 'gateway-stream', 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'user_id' => $userId, 'scopes' => $scopes, 'exp' => $expiresAt, 'iat' => time(), 'jti' => bin2hex(random_bytes(12)), ]); return [ 'token' => $token, 'gateway_id' => (int)$gateway->id, 'expires_at' => $this->formatDateTime($expiresAt), 'scopes' => $scopes, 'broker_url' => $this->buildBrokerPublicUrl(), 'ws_url' => $this->buildBrokerPublicWebSocketUrl('/ws/browser-gateway-stream'), ]; } public function validateBrowserStreamToken(string $token): array { $payload = $this->parseSignedBrokerToken($token); if (($payload['session_type'] ?? null) !== 'gateway-stream') { throw new Exception('Invalid gateway stream token'); } return $payload; } /** * @throws Exception */ public function createShellSession( int $gatewayId, ?int $userId, string $reason = '', ?int $cols = null, ?int $rows = null, ?string $cwd = null ): array { $gateway = $this->requireGateway($gatewayId); $readiness = $this->buildShellReadinessDiagnostics($gateway); if (empty($readiness['ready'])) { throw new edge_gateway_operation_exception( (string)$readiness['message'], (string)$readiness['reason_code'], 409, $readiness ); } $sessionToken = bin2hex(random_bytes(32)); $expiresAt = $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS); $brokerUrl = $this->buildBrokerPublicUrl(); $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'); $sessionObject = new edge_gateway_shell_sessions_o(); $sessionId = $sessionObject->add_object([ 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'actor_user_id' => $userId, 'session_token_hash' => $this->hashToken($sessionToken), 'status' => 'PENDING', 'reason' => trim($reason) !== '' ? trim($reason) : 'Diagnostic shell session', 'cwd' => $cwd ?: self::DEFAULT_INSTALL_DIR, 'shell_command' => null, 'shell_args_json' => [], 'cols' => $cols, 'terminal_rows' => $rows, 'transcript' => null, 'metadata_json' => [ 'root_dir' => self::DEFAULT_INSTALL_DIR, 'shell_diagnostics' => $readiness, 'requested_broker_url' => $brokerUrl, 'requested_ws_url' => $wsUrl, 'shortcut_paths' => [ self::DEFAULT_INSTALL_DIR, self::DEFAULT_RUNTIME_DIR, ], ], 'expires_at' => $expiresAt, 'approved_at' => $this->now(), 'opened_at' => null, 'closed_at' => null, ]); $session = $sessionObject->select($sessionId)->asArray(); $this->writeAudit( (int)$gateway->id, (int)$gateway->department_id->value(), 'GATEWAY_SHELL_SESSION_CREATED', $userId, ['shell_session_id' => $sessionId, 'reason' => $session['reason']] ); return [ 'session' => $session, 'token' => $sessionToken, 'gateway_id' => (int)$gateway->id, 'expires_at' => $expiresAt, 'broker_url' => $brokerUrl, 'ws_url' => $wsUrl, 'diagnostics' => $readiness, ]; } /** * @throws Exception */ public function validateShellSessionToken(string $plainToken): array { $session = $this->findShellSessionByToken($plainToken); $status = strtoupper((string)$session->status->value()); if (!in_array($status, ['PENDING', 'OPEN'], true)) { throw new Exception('Shell session is closed'); } $expiresAt = self::parseApplicationDateTime( $session->expires_at->value() === null ? null : (string)$session->expires_at->value() ); if ($expiresAt !== null && $expiresAt <= time()) { $session->status->set('EXPIRED'); $session->closed_at->set($this->now()); throw new Exception('Shell session expired'); } return $session->asArray(); } /** * @throws Exception */ public function markShellSessionOpened(string $plainToken, ?string $connectionId = null): array { $session = $this->findShellSessionByToken($plainToken); if ((string)$session->status->value() !== 'OPEN') { $session->status->set('OPEN'); $session->opened_at->set($this->now()); } if ($connectionId !== null && trim($connectionId) !== '') { $session->connection_id->set(trim($connectionId)); } $this->writeAudit( (int)$session->gateway_id->value(), (int)$session->department_id->value(), 'GATEWAY_SHELL_SESSION_OPENED', $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(), ['shell_session_id' => (int)$session->id] ); return $session->asArray(); } /** * @throws Exception */ public function closeShellSessionByToken( string $plainToken, string $transcript = '', ?string $reason = null, array $details = [] ): array { $session = $this->findShellSessionByToken($plainToken); $metadata = (array)($session->metadata_json->value() ?? []); $closeDiagnostics = $this->normalizeShellCloseDiagnostics($reason, $details); $metadata['close_reason'] = $reason; $metadata['close_message'] = $closeDiagnostics['message'] ?? null; $metadata['close_code'] = $closeDiagnostics['code'] ?? null; $metadata['close_stage'] = $closeDiagnostics['stage'] ?? null; $metadata['close_diagnostics'] = $closeDiagnostics; $metadata['transcript_bytes'] = strlen($transcript); $session->status->set($reason === 'agent_exit' ? 'COMPLETED' : 'CLOSED'); $session->closed_at->set($this->now()); $session->transcript->set($transcript); $session->metadata_json->set($metadata); $this->writeAudit( (int)$session->gateway_id->value(), (int)$session->department_id->value(), 'GATEWAY_SHELL_SESSION_CLOSED', $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(), ['shell_session_id' => (int)$session->id, 'reason' => $reason] ); edge_gateway_view_cache::syncGateway($this->getGateway((int)$session->gateway_id->value())); return $session->asArray(); } private function normalizeShellCloseDiagnostics(?string $reason, array $details = []): array { $nestedDetails = isset($details['details']) && is_array($details['details']) ? (array)$details['details'] : []; $message = self::trimInstallSessionText( $details['message'] ?? $nestedDetails['message'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT ); $stage = self::trimInstallSessionText( $details['stage'] ?? $details['failure_stage'] ?? $nestedDetails['stage'] ?? null, 128 ); $codeValue = $details['code'] ?? $details['close_code'] ?? $nestedDetails['code'] ?? null; $code = is_numeric($codeValue) ? (int)$codeValue : null; $diagnostics = [ 'reason' => self::trimInstallSessionText($reason, 128), 'message' => $message, 'code' => $code, 'stage' => $stage, 'was_clean' => array_key_exists('was_clean', $details) ? (bool)$details['was_clean'] : null, 'connection_id' => self::trimInstallSessionText( $details['connection_id'] ?? $details['broker_connection_id'] ?? $nestedDetails['connection_id'] ?? null, 128 ), 'broker_url' => self::trimInstallSessionText($details['broker_url'] ?? $nestedDetails['broker_url'] ?? null, 512), 'ws_url' => self::trimInstallSessionText( $this->redactShellDiagnosticUrl($details['ws_url'] ?? $nestedDetails['ws_url'] ?? null), 512 ), 'closed_at' => $this->now(), ]; return array_filter($diagnostics, static fn(mixed $value): bool => $value !== null && $value !== ''); } private function redactShellDiagnosticUrl(mixed $value): ?string { if ($value === null) { return null; } $url = trim((string)$value); if ($url === '') { return null; } return (string)preg_replace('/([?&](?:token|agentToken|agent_token)=)[^&]*/i', '$1***', $url); } /** * @throws Exception */ public function appendGatewayLogEntry( int $gatewayId, string $message, string $level = 'INFO', string $stream = 'agent', string $source = 'BROKER', array $context = [] ): array { $gateway = $this->requireGateway($gatewayId); $logEntryId = (new edge_gateway_log_entries_o())->add_object([ 'gateway_id' => $gatewayId, 'department_id' => (int)$gateway->department_id->value(), 'level' => strtoupper(trim($level)) ?: 'INFO', 'stream' => trim($stream) !== '' ? trim($stream) : 'agent', 'source' => trim($source) !== '' ? trim($source) : 'BROKER', 'message' => $message, 'context_json' => $context, ]); edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); return (new edge_gateway_log_entries_o())->select($logEntryId)->asArray(); } /** * @throws Exception */ public function appendRelayTransportLog( int $departmentId, string $endpoint, array $payload, array|object|null $response, string $handler = 'cloud', ?string $errorMessage = null, array $actionContext = [] ): ?array { try { $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); } catch (Exception) { return null; } $relayId = $this->inferRelayIdFromTransportPayload($payload); $success = $errorMessage === null || trim($errorMessage) === ''; $targetOn = array_key_exists('on', $payload) ? (bool)$payload['on'] : null; $handler = strtolower(trim($handler)) === 'local' ? 'local' : 'cloud'; return $this->appendRelayDispatchLog( [ 'id' => null, 'gateway_id' => (int)$gateway->id, 'department_id' => $departmentId, 'relay_id' => $relayId, 'device_id' => $payload['deviceId'] ?? $payload['device_id'] ?? null, 'channel' => $payload['channel'] ?? null, ], [ 'execution_path' => $handler, 'delivery_channel' => $handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : self::DELIVERY_CHANNEL_API, 'reason' => 'direct_transport', ], $success, [ 'action' => $this->inferRelayActionFromEndpoint($endpoint), 'handler' => $handler, 'relay_id' => $relayId, 'target_on' => $targetOn, 'toggle_after_seconds' => $this->normalizeRelayToggleAfter( isset($payload['toggle_after']) || isset($payload['toggleAfter']) || isset($payload['timer']) ? (int)($payload['toggle_after'] ?? $payload['toggleAfter'] ?? $payload['timer']) : null ), 'signal' => [ 'endpoint' => $endpoint, 'request' => $payload, ], 'action_context' => $this->normalizeRelayActionContext($actionContext), ], $this->normalizeRelayTransportResponse($response), $success ? null : new Exception($errorMessage ?? 'Relay transport request failed') ); } /** * @throws Exception */ public function recordTelemetryFromBroker(int $gatewayId, array $payload): array { $gateway = $this->requireGateway($gatewayId); $now = $this->now(); $metadata = array_merge( (array)($gateway->metadata_json->value() ?? []), isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] ); $existingPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) ? (array)$metadata['broker_presence'] : []; $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata']) ? (array)$existingPresence['metadata'] : []; $payloadMetadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; $connectionId = trim((string)($payload['broker_connection_id'] ?? $existingPresence['connection_id'] ?? '')); $presence = [ 'gateway_id' => $gatewayId, 'connected' => true, 'connection_id' => $connectionId !== '' ? $connectionId : null, 'last_seen_at' => $now, 'disconnect_reason' => null, 'last_error' => null, 'metadata' => array_merge($presenceMetadata, array_filter([ 'agent_instance_id' => $payload['broker_agent_instance_id'] ?? $payloadMetadata['agent_instance_id'] ?? null, ], static fn(mixed $value): bool => $value !== null && $value !== '')), ]; $metadata['broker_presence'] = $presence; $metadata['broker_connected'] = true; $metadata['broker_connected_at'] = $now; $metadata['broker_last_error'] = null; $this->writeBrokerPresence($gatewayId, $presence); $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->last_heartbeat_at->set($now); $gateway->metadata_json->set($metadata); if (isset($payload['inventory']) && is_array($payload['inventory'])) { $this->syncDeviceInventory($gatewayId, (array)$payload['inventory']); } $gatewayPayload = $this->getGateway($gatewayId); edge_gateway_view_cache::syncGateway($gatewayPayload); return $gatewayPayload; } private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array { return [ 'targetVersion' => $targetVersion, 'releaseChannel' => $releaseChannel, 'artifactUrl' => $this->buildAgentArtifactUrl('agent.php'), 'artifactSha256' => $this->buildAgentArtifactSha256('agent.php'), 'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), 'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME), 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'runtimeMode' => 'compose', 'installDir' => self::DEFAULT_INSTALL_DIR, 'runtimeDir' => self::DEFAULT_RUNTIME_DIR, 'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME, 'stackServiceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME), 'stackServiceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_STACK_SERVICE_NAME), 'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE, 'composeFileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE), 'composeFileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_COMPOSE_STACK_FILE), 'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME, 'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME, 'launcherScriptUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME), 'launcherScriptSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAUNCHER_SCRIPT_NAME), 'lanWorkerArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT), 'lanWorkerArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_ARTIFACT), 'autoUpdaterArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT), 'autoUpdaterArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_ARTIFACT), 'edgeAgentDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE), 'edgeAgentDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_EDGE_AGENT_DOCKERFILE), 'lanWorkerDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE), 'lanWorkerDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_DOCKERFILE), 'autoUpdaterDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), 'autoUpdaterDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_DOCKERFILE), 'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH, 'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL, 'updateWindow' => self::configuredDefaultUpdateWindow(), 'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE, 'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE, 'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE, 'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE, 'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE, 'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE, ]; } private function buildAgentArtifactUrl(string $fileName): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName; } private function buildAgentArtifactPath(string $fileName): string { return edge_gateway_agent_artifact_locator::resolve($fileName); } /** * @throws Exception */ private function buildAgentArtifactSha256(string $fileName): string { $artifactPath = $this->buildAgentArtifactPath($fileName); if (!is_file($artifactPath)) { throw new Exception('Missing edge agent artifact: ' . $fileName); } $sha256 = hash_file('sha256', $artifactPath); if ($sha256 === false) { throw new Exception('Unable to checksum edge agent artifact: ' . $fileName); } return $sha256; } /** * @throws Exception */ public function verifyInstallToken(string $plainToken): array { $claimToken = $this->requireClaimToken($plainToken); if ($claimToken->used_at->value() !== null) { throw new Exception('Install token has already been used'); } return [ 'valid' => true, 'claim_token_id' => (int)$claimToken->id, 'department_id' => (int)$claimToken->department_id->value(), 'label' => $claimToken->label->value(), 'expires_at' => (string)$claimToken->expires_at->value(), ]; } /** * @throws Exception */ public function getInstallTokenStatus(int $claimTokenId): array { return $this->buildInstallTokenStatusPayload($this->requireClaimTokenById($claimTokenId)); } /** * @throws Exception */ public function reportInstallTokenStatus(string $plainToken, array $payload): array { $claimToken = $this->requireClaimToken($plainToken); $status = strtoupper(trim((string)($payload['status'] ?? self::INSTALL_SESSION_STATUS_RUNNING))); $step = trim((string)($payload['step'] ?? ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : $status))); $message = trim((string)($payload['message'] ?? '')); $update = [ 'status' => $status, 'step' => $step !== '' ? $step : ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : null), 'message' => $message !== '' ? $message : null, 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], ]; if (array_key_exists('gateway_id', $payload)) { $update['gateway_id'] = (int)$payload['gateway_id']; } if (array_key_exists('last_error', $payload)) { $update['last_error'] = $payload['last_error']; } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED) { $update['last_error'] = $message !== '' ? $message : 'Installer failed.'; } elseif ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { $update['last_error'] = null; } return $this->persistInstallSession($claimToken, $update); } public static function installSessionStatusIsTerminal(string $status): bool { return in_array( strtoupper(trim($status)), [ self::INSTALL_SESSION_STATUS_CLAIMED, self::INSTALL_SESSION_STATUS_FAILED, self::INSTALL_SESSION_STATUS_EXPIRED, ], true ); } /** * @param array $session * @param array $update * @return array */ public static function mergeInstallSessionUpdate(array $session, array $update, ?int $now = null): array { $timestamp = date('Y-m-d H:i:s', $now ?? time()); $status = strtoupper(trim((string)($update['status'] ?? $session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING))); $step = trim((string)($update['step'] ?? $session['step'] ?? '')); $message = self::trimInstallSessionText($update['message'] ?? ($session['message'] ?? null), self::INSTALL_SESSION_OUTPUT_LIMIT); $startedAt = isset($session['started_at']) ? self::trimInstallSessionText($session['started_at'], 64) : null; if ($startedAt === null && $status !== self::INSTALL_SESSION_STATUS_PENDING) { $startedAt = $timestamp; } $gatewayId = array_key_exists('gateway_id', $update) ? (int)$update['gateway_id'] : (int)($session['gateway_id'] ?? 0); $lastError = array_key_exists('last_error', $update) ? self::trimInstallSessionText($update['last_error'], self::INSTALL_SESSION_OUTPUT_LIMIT) : self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT); if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { $lastError = null; } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED && $lastError === null) { $lastError = $message ?? 'Installer failed.'; } $diagnostics = array_key_exists('diagnostics', $update) ? self::sanitizeInstallSessionDiagnostics($update['diagnostics']) : self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []); if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) { $diagnostics = []; } $events = self::sanitizeInstallSessionEvents($session['events'] ?? []); $shouldRecordEvent = !array_key_exists('record_event', $update) || $update['record_event'] !== false; if ($shouldRecordEvent) { $events[] = array_filter([ 'status' => $status, 'step' => $step !== '' ? $step : null, 'message' => $message, 'at' => $timestamp, ], static fn(mixed $value): bool => $value !== null && $value !== ''); } $events = self::sanitizeInstallSessionEvents($events); return [ 'status' => $status, 'step' => $step !== '' ? $step : null, 'message' => $message, 'started_at' => $startedAt, 'updated_at' => $timestamp, 'gateway_id' => $gatewayId > 0 ? $gatewayId : null, 'last_error' => $lastError, 'diagnostics' => $diagnostics, 'events' => $events, ]; } /** * @param array $session * @return array */ public static function normalizeInstallSessionRecord(array $session, ?string $expiresAt, ?int $now = null): array { $normalized = [ 'status' => strtoupper(trim((string)($session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING))), 'step' => self::trimInstallSessionText($session['step'] ?? null, 64), 'message' => self::trimInstallSessionText($session['message'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT), 'started_at' => self::trimInstallSessionText($session['started_at'] ?? null, 64), 'updated_at' => self::trimInstallSessionText($session['updated_at'] ?? null, 64), 'gateway_id' => (($session['gateway_id'] ?? null) !== null && (int)$session['gateway_id'] > 0) ? (int)$session['gateway_id'] : null, 'last_error' => self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT), 'diagnostics' => self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []), 'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []), ]; $status = (string)$normalized['status']; if ( !self::installSessionStatusIsTerminal($status) && $expiresAt !== null && (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time()) ) { $status = self::INSTALL_SESSION_STATUS_EXPIRED; $normalized['status'] = $status; $normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.'; $normalized['last_error'] = $normalized['last_error'] ?: 'Install token expired.'; } $normalized['terminal'] = self::installSessionStatusIsTerminal((string)$status); return $normalized; } /** * @param mixed $diagnostics * @return array> */ private static function sanitizeInstallSessionDiagnostics(mixed $diagnostics): array { if (!is_array($diagnostics)) { return []; } $normalized = []; foreach ($diagnostics as $diagnostic) { if (is_string($diagnostic)) { $output = self::trimInstallSessionText($diagnostic, self::INSTALL_SESSION_OUTPUT_LIMIT); if ($output === null) { continue; } $normalized[] = [ 'name' => 'Diagnostic', 'output' => $output, ]; continue; } if (!is_array($diagnostic)) { continue; } $name = self::trimInstallSessionText($diagnostic['name'] ?? $diagnostic['title'] ?? null, 120); $output = self::trimInstallSessionText($diagnostic['output'] ?? $diagnostic['body'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT); if ($name === null || $output === null) { continue; } $normalized[] = [ 'name' => $name, 'output' => $output, ]; } return array_slice($normalized, -self::INSTALL_SESSION_DIAGNOSTIC_LIMIT); } /** * @param mixed $events * @return array> */ private static function sanitizeInstallSessionEvents(mixed $events): array { if (!is_array($events)) { return []; } $normalized = []; foreach ($events as $event) { if (!is_array($event)) { continue; } $status = self::trimInstallSessionText($event['status'] ?? null, 32); $step = self::trimInstallSessionText($event['step'] ?? null, 64); $message = self::trimInstallSessionText($event['message'] ?? null, 512); $at = self::trimInstallSessionText($event['at'] ?? null, 64); $normalized[] = array_filter([ 'status' => $status, 'step' => $step, 'message' => $message, 'at' => $at, ], static fn(mixed $value): bool => $value !== null && $value !== ''); } return array_slice($normalized, -self::INSTALL_SESSION_EVENT_LIMIT); } private static function trimInstallSessionText(mixed $value, int $limit): ?string { if ($value === null) { return null; } $text = trim((string)$value); if ($text === '') { return null; } if (strlen($text) <= $limit) { return $text; } return substr($text, 0, max(0, $limit - 3)) . '...'; } /** * @throws Exception */ private function buildInstallTokenStatusPayload(edge_gateway_claim_tokens_o $claimToken): array { $metadata = (array)($claimToken->metadata_json->value() ?? []); $session = self::normalizeInstallSessionRecord( isset($metadata['install_session']) && is_array($metadata['install_session']) ? (array)$metadata['install_session'] : [], (string)$claimToken->expires_at->value() ); return [ 'claim_token_id' => (int)$claimToken->id, 'department_id' => (int)$claimToken->department_id->value(), 'label' => $claimToken->label->value() === null ? null : (string)$claimToken->label->value(), 'expires_at' => (string)$claimToken->expires_at->value(), 'status' => (string)$session['status'], 'step' => $session['step'] ?? null, 'message' => $session['message'] ?? null, 'started_at' => $session['started_at'] ?? null, 'updated_at' => $session['updated_at'] ?? null, 'terminal' => (bool)($session['terminal'] ?? false), 'gateway_id' => $session['gateway_id'] ?? null, 'last_error' => $session['last_error'] ?? null, 'diagnostics' => $session['diagnostics'] ?? [], 'events' => $session['events'] ?? [], ]; } /** * @throws Exception */ private function persistInstallSession(edge_gateway_claim_tokens_o $claimToken, array $update): array { $metadata = (array)($claimToken->metadata_json->value() ?? []); $metadata['install_session'] = self::mergeInstallSessionUpdate( isset($metadata['install_session']) && is_array($metadata['install_session']) ? (array)$metadata['install_session'] : [], $update ); $claimToken->metadata_json->set($metadata); return $this->buildInstallTokenStatusPayload($claimToken); } public function getApiBaseUrl(): string { $configured = trim((string)(getenv('EDGE_PUBLIC_API_URL') ?: '')); if ($configured !== '') { return $configured; } $forwardedScheme = $this->detectForwardedScheme(); if ($forwardedScheme !== null) { $scheme = strtolower($forwardedScheme) === 'https' ? 'https' : 'http'; } else { $requestScheme = strtolower(trim((string)($_SERVER['REQUEST_SCHEME'] ?? ''))); $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $requestScheme === 'https' ? 'https' : 'http'; } $host = trim((string)($this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_HOST'] ?? null) ?? ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'))); if ($host === '') { $host = 'localhost'; } $forwardedPort = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PORT'] ?? null); $port = $forwardedPort !== null ? (int)$forwardedPort : 0; if ($port <= 0) { $hostPort = parse_url($scheme . '://' . $host, PHP_URL_PORT); $port = is_int($hostPort) ? $hostPort : (int)($_SERVER['SERVER_PORT'] ?? 0); } if ($scheme === 'http' && in_array($port, [443, 4433], true)) { $scheme = 'https'; } if ($port > 0 && !str_contains($host, ':') && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { $host .= ':' . $port; } $forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null); if ($forwardedPrefix !== null) { $normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/'); $basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix; } else { $requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); $basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : ''; } return $scheme . '://' . $host . $basePath; } private function detectForwardedScheme(): ?string { $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null); if ($forwardedScheme !== null) { return $forwardedScheme; } $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTOCOL'] ?? null); if ($forwardedScheme !== null) { return $forwardedScheme; } $forwardedHeader = trim((string)($_SERVER['HTTP_FORWARDED'] ?? '')); if ($forwardedHeader !== '' && preg_match('/proto=([^;,\s]+)/i', $forwardedHeader, $matches) === 1) { return trim($matches[1], "\"'"); } return null; } private function firstForwardedHeaderValue(mixed $value): ?string { if (!is_string($value)) { return null; } foreach (explode(',', $value) as $segment) { $normalized = trim($segment); if ($normalized !== '') { return $normalized; } } return null; } /** * @throws Exception */ private function requireDispatchableGateway(int $gatewayId): edge_gateways_o { $gateway = $this->requireGateway($gatewayId); $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Gateway agent is offline'); } return $gateway; } /** * @throws Exception */ private function requireGateway(int $gatewayId): edge_gateways_o { $gateway = (new edge_gateways_o())->select($gatewayId); if (!$gateway->exists() || $gateway->deleted_at->value() !== null) { throw new Exception('Edge gateway not found'); } if (!$this->departmentExists((int)$gateway->department_id->value())) { $this->softDeleteOrphanedGateway($gateway); throw new Exception('Edge gateway not found'); } return $gateway; } private function departmentExists(int $departmentId): bool { if ($departmentId <= 0) { return false; } try { return (new departments_o())->select($departmentId)->exists(); } catch (\Throwable) { return true; } } private function softDeleteOrphanedGateway(edge_gateways_o $gateway): void { $gatewayId = (int)$gateway->id; $departmentId = (int)$gateway->department_id->value(); try { $this->softDeleteGatewayRelations($gatewayId); } catch (\Throwable) { } try { if ($gateway->deleted_at->value() === null) { $gateway->deleted_at->set($this->now()); } } catch (\Throwable) { } edge_gateway_view_cache::removeGateway($gatewayId, $departmentId > 0 ? $departmentId : null); } private function softDeleteGatewayRelations(int $gatewayId): void { $tables = [ 'edge_gateway_device_inventory', 'edge_gateway_relay_bindings', 'edge_gateway_command_jobs', 'edge_gateway_operations', ]; $pdo = db::getPDO(); $deletedAt = $this->now(); foreach ($tables as $table) { $statement = $pdo->prepare( "UPDATE {$table} SET deleted_at = :deleted_at WHERE gateway_id = :gateway_id AND deleted_at IS NULL" ); $statement->execute([ ':deleted_at' => $deletedAt, ':gateway_id' => $gatewayId, ]); } } /** * @throws Exception */ private function requireDepartment(int $departmentId): departments_o { $department = (new departments_o())->select($departmentId); if (!$department->exists()) { throw new Exception('Department not found'); } return $department; } /** * @throws Exception */ private function requireClaimToken(string $plainToken): edge_gateway_claim_tokens_o { $rows = (new edge_gateway_claim_tokens_o())->getFieldsWhere([ 'token_hash' => $this->hashToken($plainToken), 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('Invalid install token'); } $claimToken = (new edge_gateway_claim_tokens_o())->select((int)$rows[0]['id']); if (!$claimToken->exists()) { throw new Exception('Invalid install token'); } if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) { throw new Exception('Install token has expired'); } return $claimToken; } /** * @throws Exception */ private function requireClaimTokenById(int $claimTokenId): edge_gateway_claim_tokens_o { if ($claimTokenId <= 0) { throw new Exception('Invalid install token'); } $claimToken = (new edge_gateway_claim_tokens_o())->select($claimTokenId); if (!$claimToken->exists() || $claimToken->deleted_at->value() !== null) { throw new Exception('Invalid install token'); } return $claimToken; } /** * @throws Exception */ private function getPrimaryGatewayForDepartment(int $departmentId, bool $requireDispatchable = true): edge_gateways_o { $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('No edge gateway found for department'); } $gatewayIds = array_map(static fn(array $row): int => (int)$row['id'], $rows); $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { $aStatus = self::resolveGatewayStatus( $a->status->value() === null ? null : (string)$a->status->value(), $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() ); $bStatus = self::resolveGatewayStatus( $b->status->value() === null ? null : (string)$b->status->value(), $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() ); return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); }); $gateway = $gateways[0]; $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if ($requireDispatchable && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Department edge gateway is offline'); } return $gateway; } /** * @throws Exception */ private function setGatewayPrimaryState(edge_gateways_o $gateway, bool $isPrimary): void { if ($isPrimary) { $statement = db::getPDO()->prepare( "UPDATE edge_gateways SET is_primary = CASE WHEN id = :gateway_id THEN 1 ELSE 0 END WHERE department_id = :department_id AND deleted_at IS NULL" ); $statement->execute([ ':gateway_id' => (int)$gateway->id, ':department_id' => (int)$gateway->department_id->value(), ]); $gateway->is_primary->set(true); return; } $replacement = $this->findAlternateGatewayForDepartment( (int)$gateway->department_id->value(), (int)$gateway->id ); if ($replacement === null) { throw new Exception('Department must retain a primary gateway'); } $replacement->is_primary->set(true); $gateway->is_primary->set(false); } private function findAlternateGatewayForDepartment(int $departmentId, int $excludedGatewayId): ?edge_gateways_o { $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); $gatewayIds = array_values(array_filter( array_map(static fn(array $row): int => (int)$row['id'], $rows), static fn(int $gatewayId): bool => $gatewayId !== $excludedGatewayId )); if ($gatewayIds === []) { return null; } $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { $aStatus = self::resolveGatewayStatus( $a->status->value() === null ? null : (string)$a->status->value(), $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() ); $bStatus = self::resolveGatewayStatus( $b->status->value() === null ? null : (string)$b->status->value(), $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() ); return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); }); return $gateways[0] ?? null; } private function createCommandJob( int $gatewayId, string $commandType, array $request, ?int $userId, array $delivery = [] ): edge_gateway_command_jobs_o { $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::COMMAND_EXPIRES_AFTER_SECONDS); $jobObject = new edge_gateway_command_jobs_o(); $jobId = $jobObject->add_object([ 'gateway_id' => $gatewayId, 'command_type' => $commandType, 'status' => 'PENDING', 'request_json' => $request, 'response_json' => [], 'delivery_json' => $deliveryMetadata, 'correlation_id' => bin2hex(random_bytes(16)), 'requested_by' => $userId, 'requested_at' => $this->now(), ]); return $jobObject->select($jobId); } private function expireTimedOutRelayStatusCommandJobs(int $gatewayId): void { if ($gatewayId <= 0) { return; } $statement = db::getPDO()->prepare( "SELECT id FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND command_type = 'GET_RELAY_STATUS' AND status IN ('PENDING', 'DISPATCHING') AND requested_at <= :cutoff ORDER BY requested_at ASC, id ASC LIMIT 200" ); $statement->execute([ ':gateway_id' => $gatewayId, ':cutoff' => $this->formatDateTime(time() - self::COMMAND_WAIT_TIMEOUT_SECONDS), ]); foreach ($statement->fetchAll() ?: [] as $row) { $jobId = (int)($row['id'] ?? 0); if ($jobId <= 0) { continue; } $job = (new edge_gateway_command_jobs_o())->select($jobId); if ($job->exists()) { $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT'); } } } /** * @throws Exception */ private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array { $deadline = microtime(true) + max(0, $timeoutSeconds); do { $job = (new edge_gateway_command_jobs_o())->select($jobId); if (!$job->exists()) { throw new Exception('Edge gateway command job not found'); } $status = (string)$job->status->value(); if ($status === 'COMPLETED') { $response = (array)($job->response_json->value() ?? []); return (array)($response['payload'] ?? []); } if ($status === 'FAILED') { $errorMessage = trim((string)($job->error_message->value() ?? '')); throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed'); } if ($status === 'TIMED_OUT') { $errorMessage = trim((string)($job->error_message->value() ?? '')); throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out'); } if (microtime(true) >= $deadline) { break; } usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); } while (true); $job = (new edge_gateway_command_jobs_o())->select($jobId); if ($job->exists()) { $status = (string)$job->status->value(); if ($status === 'COMPLETED') { $response = (array)($job->response_json->value() ?? []); return (array)($response['payload'] ?? []); } if ($status === 'FAILED') { $errorMessage = trim((string)($job->error_message->value() ?? '')); throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed'); } if ($status === 'TIMED_OUT') { $errorMessage = trim((string)($job->error_message->value() ?? '')); throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out'); } $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT'); } throw new Exception('Edge gateway command timed out'); } private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o { $pdo = db::getPDO(); $pdo->beginTransaction(); try { $statement = $pdo->prepare( 'SELECT id, delivery_json FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND ( status = :pending_status_match OR ( status = :dispatching_status_match AND COALESCE(updated_at, created_at, requested_at) <= :stale_before ) ) ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC LIMIT 1 FOR UPDATE' ); $statement->execute([ ':gateway_id' => (int)$gateway->id, ':pending_status_match' => 'PENDING', ':dispatching_status_match' => 'DISPATCHING', ':pending_status_order' => 'PENDING', ':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS), ]); $row = $statement->fetch(); if (!is_array($row) || !isset($row['id'])) { $pdo->commit(); return null; } $delivery = isset($row['delivery_json']) && is_string($row['delivery_json']) ? json_decode($row['delivery_json'], true) : []; if (!is_array($delivery)) { $delivery = []; } $delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API; $delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1; $delivery['last_dispatch_error'] = null; $encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($encodedDelivery)) { $encodedDelivery = '{}'; } $update = $pdo->prepare( 'UPDATE edge_gateway_command_jobs SET status = :status, response_json = :response_json, delivery_json = :delivery_json, error_message = NULL, completed_at = NULL WHERE id = :id' ); $update->execute([ ':status' => 'DISPATCHING', ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE), ':delivery_json' => $encodedDelivery, ':id' => (int)$row['id'], ]); $pdo->commit(); } catch (\Throwable $throwable) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $throwable; } $this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']); return (new edge_gateway_command_jobs_o())->select((int)$row['id']); } private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array { return [ 'id' => (int)$job->id, 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'command_type' => (string)$job->command_type->value(), 'commandType' => (string)$job->command_type->value(), 'payload' => $this->buildCommandExecutionPayload($job, $gateway), 'requested_at' => (string)$job->requested_at->value(), ]; } private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array { return array_merge([ 'jobId' => (int)$job->id, 'gatewayId' => (int)$gateway->id, 'departmentId' => (int)$gateway->department_id->value(), 'correlationId' => (string)$job->correlation_id->value(), ], (array)($job->request_json->value() ?? [])); } private function finalizeCommandJob( edge_gateway_command_jobs_o $job, bool $ok, array $payload = [], ?string $errorMessage = null, ?edge_gateways_o $gateway = null, ?string $terminalStatus = null ): void { $gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value()); $response = [ 'ok' => $ok, 'payload' => $payload, ]; if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') { $response['error'] = $errorMessage; } $job->response_json->set($response); $job->delivery_json->set($this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, 'last_dispatch_error' => $ok ? null : $errorMessage, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS )); $job->completed_at->set($this->now()); $job->error_message->set($ok ? null : $errorMessage); $job->status->set($ok ? 'COMPLETED' : $this->normalizeCommandFailureStatus($terminalStatus)); $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage); } private function normalizeCommandFailureStatus(?string $status): string { $normalized = strtoupper(trim((string)$status)); return in_array($normalized, ['FAILED', 'TIMED_OUT'], true) ? $normalized : 'FAILED'; } private function applyCommandResult( edge_gateways_o $gateway, edge_gateway_command_jobs_o $job, bool $ok, array $payload, ?string $errorMessage ): void { unset($errorMessage); if ((string)$job->command_type->value() === 'DISCOVER_SHELLY') { if ($ok) { $inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : []; $this->syncDeviceInventory((int)$gateway->id, $inventory); $gateway->discovery_status->set('READY'); } else { $gateway->discovery_status->set('FAILED'); } } } private function resolveRelayBindingLocalIp( int $gatewayId, array $binding, string $deviceId, mixed $existingLocalIp = null ): ?string { $incomingLocalIp = $this->normalizeLocalIp( $binding['local_ip'] ?? $binding['localIp'] ?? $binding['ip'] ?? $binding['metadata']['local_ip'] ?? null ); if ($incomingLocalIp !== null) { return $incomingLocalIp; } $preservedLocalIp = $this->normalizeLocalIp($existingLocalIp); if ($preservedLocalIp !== null) { return $preservedLocalIp; } $inventoryLocalIp = $this->findInventoryLocalIp($gatewayId, $deviceId); if ($inventoryLocalIp !== null) { return $inventoryLocalIp; } return $this->findShellyCloudRelayLocalIp( $deviceId, trim((string)($binding['relay_id'] ?? '')) ); } private function findInventoryLocalIp(int $gatewayId, string $deviceId): ?string { $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'deleted_at' => null, ], ['id']); if ($rows === []) { return null; } $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); return $this->normalizeLocalIp($inventoryObject->local_ip->value()); } private function findShellyCloudRelayLocalIp(string $deviceId, string $relayId): ?string { foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { $optionDeviceId = trim((string)($option['device_id'] ?? '')); $optionRelayId = trim((string)($option['id'] ?? '')); if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) { continue; } $localIp = $this->normalizeLocalIp( $option['local_ip'] ?? $option['localIp'] ?? $option['ip'] ?? null ); if ($localIp !== null) { return $localIp; } } return null; } /** * @return array> */ private function listShellyRelayOptionsForLocalIpLookup(): array { if (is_array($this->shellyRelayOptionsCache)) { return $this->shellyRelayOptionsCache; } try { $this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(); } catch (\Throwable) { $this->shellyRelayOptionsCache = []; } return $this->shellyRelayOptionsCache; } private function normalizeLocalIp(mixed $value): ?string { $localIp = trim((string)($value ?? '')); if ($localIp === '') { return null; } return filter_var($localIp, FILTER_VALIDATE_IP) !== false ? $localIp : null; } /** * @param array $device */ private function extractDeviceLocalIp(array $device): ?string { return $this->normalizeLocalIp( $device['local_ip'] ?? $device['localIp'] ?? $device['ip'] ?? $device['metadata']['local_ip'] ?? $device['metadata']['localIp'] ?? $device['metadata']['ip'] ?? null ); } private function resolveRelayBindingDeviceGeneration(array $binding): ?int { $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $generation = $this->resolveShellyDeviceGenerationFromPayload(array_merge($metadata, $binding)); if ($generation !== null) { return $generation; } $inventoryDevice = $this->findInventoryDeviceForRelayBinding($binding); if ($inventoryDevice !== null) { $generation = $this->resolveShellyDeviceGenerationFromPayload($inventoryDevice); if ($generation !== null) { return $generation; } } $deviceId = trim((string)($binding['device_id'] ?? '')); $relayId = trim((string)($binding['relay_id'] ?? '')); foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { $optionDeviceId = trim((string)($option['device_id'] ?? '')); $optionRelayId = trim((string)($option['id'] ?? '')); if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) { continue; } $generation = $this->resolveShellyDeviceGenerationFromPayload($option); if ($generation !== null) { return $generation; } } return null; } private function findInventoryDeviceForRelayBinding(array $binding): ?array { $gatewayId = (int)($binding['gateway_id'] ?? 0); $deviceId = trim((string)($binding['device_id'] ?? '')); if ($gatewayId <= 0 || $deviceId === '') { return null; } $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'deleted_at' => null, ], ['id']); if ($rows === []) { return null; } $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); return $inventoryObject->exists() ? $inventoryObject->asArray() : null; } private function normalizeDeviceCapabilities(array $device): array { $capabilities = isset($device['capabilities']) && is_array($device['capabilities']) ? (array)$device['capabilities'] : []; if (!isset($capabilities['generation'])) { $generation = $this->resolveShellyDeviceGenerationFromPayload($device); if ($generation !== null) { $capabilities['generation'] = $generation; } } return $capabilities; } private function resolveShellyDeviceGenerationFromPayload(array $payload): ?int { $metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; $capabilities = isset($payload['capabilities']) && is_array($payload['capabilities']) ? (array)$payload['capabilities'] : []; foreach ([ $payload['deviceGeneration'] ?? null, $payload['device_generation'] ?? null, $payload['generation'] ?? null, $payload['gen'] ?? null, $capabilities['generation'] ?? null, $metadata['deviceGeneration'] ?? null, $metadata['device_generation'] ?? null, $metadata['generation'] ?? null, $metadata['gen'] ?? null, $metadata['capabilities']['generation'] ?? null, ] as $candidate) { $generation = $this->normalizeShellyDeviceGeneration($candidate); if ($generation !== null) { return $generation; } } foreach ([ $payload['device_model'] ?? null, $payload['deviceModel'] ?? null, $payload['model'] ?? null, $payload['device_type'] ?? null, $payload['deviceType'] ?? null, $payload['type'] ?? null, $payload['code'] ?? null, $metadata['device_model'] ?? null, $metadata['deviceModel'] ?? null, $metadata['model'] ?? null, $metadata['device_type'] ?? null, $metadata['deviceType'] ?? null, $metadata['type'] ?? null, $metadata['code'] ?? null, ] as $candidate) { $generation = $this->inferShellyDeviceGenerationFromString((string)($candidate ?? '')); if ($generation !== null) { return $generation; } } return null; } private function normalizeShellyDeviceGeneration(mixed $value): ?int { if (is_int($value)) { return $value > 0 ? $value : null; } if (is_numeric($value)) { $generation = (int)$value; return $generation > 0 ? $generation : null; } return $this->inferShellyDeviceGenerationFromString((string)($value ?? '')); } private function inferShellyDeviceGenerationFromString(string $value): ?int { $normalized = trim($value); if ($normalized === '') { return null; } if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) { return (int)$matches[1]; } $upper = strtoupper($normalized); if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) { return (int)$matches[1]; } if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) { return 2; } if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) { return 1; } return null; } private function backfillRelayBindingLocalIpFromInventory(int $gatewayId, string $deviceId, ?string $localIp): void { $localIp = $this->normalizeLocalIp($localIp); if ($localIp === null) { return; } $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'deleted_at' => null, ], ['id']); foreach ($rows as $row) { $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$row['id']); if ($this->normalizeLocalIp($bindingObject->local_ip->value()) !== null) { continue; } $bindingObject->local_ip->set($localIp); } } /** * @param array> $inventory */ private function syncDeviceInventory(int $gatewayId, array $inventory): void { foreach ($inventory as $device) { $deviceId = trim((string)($device['device_id'] ?? $device['id'] ?? '')); if ($deviceId === '') { continue; } $localIp = $this->extractDeviceLocalIp($device); $capabilities = $this->normalizeDeviceCapabilities($device); $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'deleted_at' => null, ], ['id']); if ($rows === []) { (new edge_gateway_device_inventory_o())->add_object([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'local_ip' => $localIp, 'model' => $device['model'] ?? null, 'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1), 'capabilities_json' => $capabilities, 'online' => (bool)($device['online'] ?? true), 'last_seen_at' => $this->now(), 'metadata_json' => (array)($device['metadata'] ?? []), ]); $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp); continue; } $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); if ($localIp !== null) { $inventoryObject->local_ip->set($localIp); } $inventoryObject->model->set($device['model'] ?? null); $inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1)); $inventoryObject->capabilities_json->set($capabilities); $inventoryObject->online->set((bool)($device['online'] ?? true)); $inventoryObject->last_seen_at->set($this->now()); $inventoryObject->metadata_json->set((array)($device['metadata'] ?? [])); $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp); } } /** * @return array> */ private function listRecentObjects(object $object, array $conditions, int $limit = 20): array { if (!method_exists($object, 'getFieldsWhere') || !method_exists($object, 'select')) { return []; } $rows = $object->getFieldsWhere($conditions, ['id']); $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows); rsort($ids); $ids = array_slice($ids, 0, $limit); $result = []; foreach ($ids as $id) { $tmp = $object::class; $selected = (new $tmp())->select($id); if (method_exists($selected, 'asArray')) { $result[] = $selected->asArray(); } } return $result; } /** * @throws Exception */ public function validateBrokerAgentConnection(int $gatewayId, string $plainToken): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); return [ 'id' => (int)$gateway->id, 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'label' => (string)$gateway->label->value(), 'hostname' => $gateway->hostname->value() === null ? null : (string)$gateway->hostname->value(), ]; } /** * @throws Exception */ public function recordBrokerPresence( int $gatewayId, string $status, ?string $connectionId = null, ?string $reason = null, array $metadata = [] ): array { $gateway = $this->requireGateway($gatewayId); $normalizedStatus = trim(strtolower($status)); $connected = $normalizedStatus === 'connected'; $presence = [ 'gateway_id' => $gatewayId, 'connected' => $connected, 'connection_id' => $connectionId, 'last_seen_at' => $this->now(), 'disconnect_reason' => $connected ? null : $reason, 'last_error' => !$connected && $reason !== null && trim($reason) !== '' ? trim($reason) : null, 'metadata' => $metadata, ]; $this->writeBrokerPresence($gatewayId, $presence); $gatewayMetadata = (array)($gateway->metadata_json->value() ?? []); $gatewayMetadata['broker_presence'] = array_merge( (array)($gatewayMetadata['broker_presence'] ?? []), $presence ); $gatewayMetadata['broker_connected'] = $connected; if ($connected) { $gatewayMetadata['broker_connected_at'] = $this->now(); $gatewayMetadata['broker_last_error'] = null; } else { $gatewayMetadata['broker_disconnected_at'] = $this->now(); $gatewayMetadata['broker_last_error'] = $reason; } $gateway->metadata_json->set($gatewayMetadata); edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); return $presence; } private function readBrokerPresence(int $gatewayId): array { $redisPresence = $this->withRedis( static fn(redis $redis): ?string => $redis->get(edge_gateway_manager::brokerPresenceKey($gatewayId)), null ); if (is_string($redisPresence) && trim($redisPresence) !== '') { $decoded = json_decode($redisPresence, true); if (is_array($decoded)) { return $decoded; } } $gateway = (new edge_gateways_o())->select($gatewayId); if ($gateway->exists()) { $metadata = (array)($gateway->metadata_json->value() ?? []); if (isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])) { return (array)$metadata['broker_presence']; } } return []; } private function writeBrokerPresence(int $gatewayId, array $presence): void { $encoded = json_encode($presence, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($encoded === false) { return; } $this->withRedis( static function (redis $redis) use ($gatewayId, $encoded): void { $redis->setEx(edge_gateway_manager::brokerPresenceKey($gatewayId), $encoded, edge_gateway_manager::BROKER_PRESENCE_TTL_SECONDS); } ); } private static function brokerPresenceKey(int $gatewayId): string { return 'edge_gateway_broker_presence_' . $gatewayId; } private function withRedis(callable $callback, mixed $fallback = null): mixed { try { return $callback(new redis()); } catch (\Throwable) { return $fallback; } } private function buildGatewayOperationalSnapshot(int $gatewayId): array { $pdo = db::getPDO(); $counts = [ 'command_backlog' => 0, 'operation_backlog' => 0, 'last_successful_command_at' => null, 'last_successful_discovery_at' => null, 'last_successful_operation_at' => null, 'last_successful_update_at' => null, ]; $countQueries = [ 'command_backlog' => "SELECT COUNT(*) AS c FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status IN ('PENDING', 'DISPATCHING')", 'operation_backlog' => "SELECT COUNT(*) AS c FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status IN ('PENDING', 'IN_PROGRESS')", ]; foreach ($countQueries as $key => $sql) { $statement = $pdo->prepare($sql); $statement->execute([':gateway_id' => $gatewayId]); $row = $statement->fetch(); $counts[$key] = isset($row['c']) ? (int)$row['c'] : 0; } $timestampQueries = [ 'last_successful_command_at' => "SELECT completed_at AS ts FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_discovery_at' => "SELECT completed_at AS ts FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' AND command_type = 'DISCOVER_SHELLY' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_operation_at' => "SELECT completed_at AS ts FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_update_at' => "SELECT completed_at AS ts FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' AND type = 'UPDATE' ORDER BY completed_at DESC, id DESC LIMIT 1", ]; foreach ($timestampQueries as $key => $sql) { $statement = $pdo->prepare($sql); $statement->execute([':gateway_id' => $gatewayId]); $row = $statement->fetch(); $counts[$key] = isset($row['ts']) ? (string)$row['ts'] : null; } return $counts; } private function buildBrokerPublicUrl(): ?string { $configured = $this->configuredPublicBrokerUrl(); if ($configured !== '') { return rtrim($configured, '/'); } $apiBaseUrl = $this->getApiBaseUrl(); if (trim($apiBaseUrl) === '') { return null; } return rtrim($apiBaseUrl, '/') . '/edge-broker'; } private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string { $brokerUrl = $this->buildBrokerPublicUrl(); if ($brokerUrl === null) { return null; } $parsed = parse_url($brokerUrl); $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'wss' : 'ws'; $host = (string)($parsed['host'] ?? ''); if ($host === '') { return null; } $port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : ''; $basePath = rtrim((string)($parsed['path'] ?? ''), '/'); $normalizedPath = '/' . ltrim($path, '/'); $fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath); return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath); } private function buildBrokerInternalUrl(): ?string { $configured = $this->configuredBrokerInternalUrl(); return $configured !== '' ? rtrim($configured, '/') : null; } private function configuredPublicBrokerUrl(): string { try { $configured = trim((string)(new edgegateway())->publicBrokerUrl()); if ($configured !== '') { return rtrim($configured, '/'); } } catch (Exception) { } $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); return $fallback !== '' ? rtrim($fallback, '/') : ''; } private function configuredBrokerInternalUrl(): string { try { $configured = trim((string)(new edgegateway())->brokerUrl()); if ($configured !== '') { return rtrim($configured, '/'); } } catch (Exception) { } $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); return $fallback !== '' ? rtrim($fallback, '/') : ''; } private function configuredBrokerAuthMode(): string { try { $configured = trim((string)(new edgegateway())->brokerAuthMode()); return $configured !== '' ? $configured : 'manager'; } catch (Exception) { } $fallback = trim((string)(getenv('EDGE_AUTH_MODE') ?: '')); return $fallback !== '' ? $fallback : 'manager'; } private function configuredBrokerSharedSecret(): string { try { $configured = trim((string)(new edgegateway())->brokerSharedSecret()); if ($configured !== '') { return $configured; } } catch (Exception) { } return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); } private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string { $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id; if ($gatewayId <= 0 || $this->buildBrokerInternalUrl() === null) { return self::DELIVERY_CHANNEL_API; } $presence = $this->readBrokerPresence($gatewayId); return self::isBrokerPresenceConnected($presence) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; } public function validateBrokerSharedSecret(?string $secret): bool { $configured = $this->configuredBrokerSharedSecret(); if ($configured === '') { return true; } return $secret !== null && hash_equals($configured, trim($secret)); } /** * @param array $options * @return array */ public function diagnoseBrokerConfiguration(array $options = []): array { $target = strtolower(trim((string)($options['target'] ?? 'all'))); $target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all'; $internalUrl = $this->normalizeBrokerDiagnosticBaseUrl( array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl() ); $publicConfigured = array_key_exists('public_broker_url', $options) ? trim((string)$options['public_broker_url']) : $this->configuredPublicBrokerUrl(); $publicUrl = $this->normalizeBrokerDiagnosticBaseUrl( $publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl() ); $sharedSecret = array_key_exists('broker_shared_secret', $options) ? trim((string)$options['broker_shared_secret']) : $this->configuredBrokerSharedSecret(); $diagnostics = [ 'target' => $target, 'checked_at' => $this->now(), 'broker_auth_mode' => array_key_exists('broker_auth_mode', $options) ? trim((string)$options['broker_auth_mode']) : $this->configuredBrokerAuthMode(), 'broker_shared_secret_configured' => $sharedSecret !== '', ]; if ($target === 'internal' || $target === 'all') { $diagnostics['internal_broker_connection'] = $this->diagnoseBrokerHttpEndpoint( $internalUrl, 'Internal broker' ); } if ($target === 'public' || $target === 'all') { $diagnostics['public_broker_url'] = $this->diagnoseBrokerHttpEndpoint( $publicUrl, 'Public broker' ); } if ($target === 'secret' || $target === 'all') { $diagnostics['broker_shared_secret'] = $this->diagnoseBrokerSharedSecret( $internalUrl, $sharedSecret ); } return $diagnostics; } private function deriveBrokerPublicUrl(): ?string { $apiBaseUrl = $this->getApiBaseUrl(); if (trim($apiBaseUrl) === '') { return null; } return rtrim($apiBaseUrl, '/') . '/edge-broker'; } /** * @return array{url:?string,error:?string} */ private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array { $url = trim((string)$value); if ($url === '') { return [ 'url' => null, 'error' => 'not_configured', ]; } $parsed = parse_url($url); $scheme = is_array($parsed) ? strtolower((string)($parsed['scheme'] ?? '')) : ''; $host = is_array($parsed) ? trim((string)($parsed['host'] ?? '')) : ''; if (!is_array($parsed) || $host === '' || !in_array($scheme, ['http', 'https'], true)) { return [ 'url' => $url, 'error' => 'invalid_url', ]; } return [ 'url' => rtrim($url, '/'), 'error' => null, ]; } /** * @param array{url:?string,error:?string} $baseUrl * @return array */ private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array { if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { return $this->brokerDiagnosticUrlFailure($baseUrl, $label); } $health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health'); if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) { return array_merge($health, [ 'ok' => true, 'status' => 'connected', 'url' => $baseUrl['url'], 'message' => $label . ' responded to the health check.', ]); } if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) { return array_merge($health, [ 'ok' => true, 'status' => 'connected_legacy', 'url' => $baseUrl['url'], 'message' => $label . ' responded, but the health endpoint is not deployed yet.', ]); } if (($health['status_code'] ?? null) !== null) { return array_merge($health, [ 'ok' => false, 'status' => 'unexpected_response', 'url' => $baseUrl['url'], 'message' => $label . ' returned HTTP ' . (string)$health['status_code'] . ' instead of the broker health response.', ]); } return array_merge($health, [ 'ok' => false, 'status' => 'unreachable', 'url' => $baseUrl['url'], 'message' => $label . ' did not respond.', ]); } /** * @param array{url:?string,error:?string} $baseUrl * @return array */ private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array { if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { return $this->brokerDiagnosticUrlFailure($baseUrl, 'Internal broker'); } $headers = $sharedSecret !== '' ? ['x-edge-broker-secret: ' . $sharedSecret] : []; $diagnostic = $this->brokerHttpProbe( $baseUrl['url'] . '/api/diagnostics/shared-secret', 'POST', [], $headers ); if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) { $required = (bool)($diagnostic['json']['shared_secret_required'] ?? false); return array_merge($diagnostic, [ 'ok' => true, 'status' => $required ? 'validated' : 'not_required', 'url' => $baseUrl['url'], 'message' => $required ? 'Broker accepted the configured shared secret.' : 'Broker responded and does not currently require a shared secret.', ]); } if (($diagnostic['status_code'] ?? null) === 403) { return array_merge($diagnostic, [ 'ok' => false, 'status' => 'secret_rejected', 'url' => $baseUrl['url'], 'message' => 'Broker rejected the configured shared secret.', ]); } if (($diagnostic['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($diagnostic)) { return $this->diagnoseBrokerSharedSecretWithLegacySync($baseUrl, $headers); } if (($diagnostic['status_code'] ?? null) !== null) { return array_merge($diagnostic, [ 'ok' => false, 'status' => 'unexpected_response', 'url' => $baseUrl['url'], 'message' => 'Broker returned HTTP ' . (string)$diagnostic['status_code'] . ' during shared secret validation.', ]); } return array_merge($diagnostic, [ 'ok' => false, 'status' => 'unreachable', 'url' => $baseUrl['url'], 'message' => 'Internal broker did not respond during shared secret validation.', ]); } /** * @param array{url:?string,error:?string} $baseUrl * @param array $headers * @return array */ private function diagnoseBrokerSharedSecretWithLegacySync(array $baseUrl, array $headers): array { $legacy = $this->brokerHttpProbe( $baseUrl['url'] . '/api/gateways/0/sync', 'POST', ['diagnostic' => true], $headers ); if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) { return array_merge($legacy, [ 'ok' => true, 'status' => 'validated_legacy', 'url' => $baseUrl['url'], 'message' => 'Broker accepted the shared secret through the legacy sync endpoint.', ]); } if (($legacy['status_code'] ?? null) === 403) { return array_merge($legacy, [ 'ok' => false, 'status' => 'secret_rejected', 'url' => $baseUrl['url'], 'message' => 'Broker rejected the configured shared secret.', ]); } return array_merge($legacy, [ 'ok' => false, 'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response', 'url' => $baseUrl['url'], 'message' => 'Broker shared secret could not be validated.', ]); } /** * @param array{url:?string,error:?string} $baseUrl * @return array */ private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array { $error = (string)($baseUrl['error'] ?? 'not_configured'); return [ 'ok' => false, 'status' => $error, 'url' => $baseUrl['url'], 'status_code' => null, 'elapsed_ms' => 0, 'message' => $error === 'invalid_url' ? $label . ' URL is not a valid http(s) URL.' : $label . ' URL is not configured.', ]; } /** * @param array $response */ private function isBrokerNotFoundProbe(array $response): bool { $json = isset($response['json']) && is_array($response['json']) ? (array)$response['json'] : []; return strtolower(trim((string)($json['error'] ?? ''))) === 'not found'; } /** * @param array $headers * @return array */ private function brokerHttpProbe( string $url, string $method = 'GET', ?array $payload = null, array $headers = [], int $timeoutSeconds = 3 ): array { $method = strtoupper(trim($method)) ?: 'GET'; $requestHeaders = array_filter(array_merge(['Accept: application/json'], $headers)); $options = [ 'method' => $method, 'header' => implode("\r\n", $requestHeaders), 'timeout' => max(1, $timeoutSeconds), 'ignore_errors' => true, ]; if ($payload !== null) { $requestHeaders[] = 'Content-Type: application/json'; $options['header'] = implode("\r\n", array_filter($requestHeaders)); $options['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $context = stream_context_create(['http' => $options]); $started = microtime(true); $body = @file_get_contents($url, false, $context); $elapsedMs = (int)round((microtime(true) - $started) * 1000); $responseHeaders = is_array($http_response_header ?? null) ? $http_response_header : []; $statusCode = $this->parseHttpStatusCode($responseHeaders); if ($body === false) { $lastError = error_get_last(); return [ 'status_code' => $statusCode, 'elapsed_ms' => $elapsedMs, 'error' => isset($lastError['message']) ? self::trimInstallSessionText($lastError['message'], 512) : null, 'json' => null, 'body_excerpt' => null, ]; } $decoded = json_decode($body, true); return [ 'status_code' => $statusCode, 'elapsed_ms' => $elapsedMs, 'error' => null, 'json' => is_array($decoded) ? $decoded : null, 'body_excerpt' => self::trimInstallSessionText($body, 512), ]; } /** * @param array $headers */ private function parseHttpStatusCode(array $headers): ?int { foreach ($headers as $header) { if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', trim((string)$header), $matches) === 1) { return (int)$matches[1]; } } return null; } /** * @throws Exception */ public function buildBrokerBacklog(int $gatewayId, ?string $agentInstanceId = null): array { $gateway = $this->requireGateway($gatewayId); $operations = new edge_gateway_operation_service($this); $dispatch = []; $operation = $operations->claimBrokerOperation($gatewayId, $agentInstanceId); if ($operation !== null) { $dispatch[] = [ 'type' => 'TASK_DISPATCH', 'taskType' => 'OPERATION', 'operation' => $operation, ]; } foreach ($operations->listBrokerCancellationRequests($gatewayId, $agentInstanceId) as $cancelledOperation) { $dispatch[] = [ 'type' => 'TASK_CANCEL', 'taskType' => 'OPERATION', 'operation' => $cancelledOperation, ]; } return [ 'gateway' => [ 'id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'label' => (string)$gateway->label->value(), ], 'dispatch' => $dispatch, ]; } public function notifyBrokerGatewaySync(int $gatewayId): void { $brokerUrl = $this->buildBrokerInternalUrl(); if ($brokerUrl === null) { return; } try { $this->httpJsonRequest( $brokerUrl . '/api/gateways/' . $gatewayId . '/sync', ['gatewayId' => $gatewayId], ['x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret()], self::BROKER_HTTP_TIMEOUT_SECONDS ); } catch (Exception) { // Broker sync is opportunistic. Legacy polling remains available during rollout. } } private function normalizeRelayBindingMetadata(array $metadata = [], array $binding = []): array { $fallbackMode = strtoupper(trim((string)($binding['fallback_mode'] ?? $metadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL))); if (!in_array($fallbackMode, [ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_LOCAL_ONLY, self::RELAY_FALLBACK_CLOUD_ONLY, ], true)) { $fallbackMode = self::RELAY_FALLBACK_PREFER_LOCAL; } $metadata['fallback_mode'] = $fallbackMode; $consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? []; if (!is_array($consumerContexts)) { $consumerContexts = []; } $consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array { if (!is_array($consumer)) { return null; } $consumerType = trim((string)($consumer['type'] ?? '')); if ($consumerType === '') { return null; } return [ 'type' => $consumerType, 'id' => isset($consumer['id']) ? (int)$consumer['id'] : null, 'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null, 'label' => isset($consumer['label']) ? (string)$consumer['label'] : null, ]; }, $consumerContexts))); $metadata['consumer_contexts'] = $consumerContexts; $metadata['consumers'] = $consumerContexts; foreach (['device_type', 'device_model', 'code'] as $field) { $value = trim((string)($binding[$field] ?? $metadata[$field] ?? '')); if ($value !== '') { $metadata[$field] = $value; } } $generation = $this->normalizeShellyDeviceGeneration( $binding['deviceGeneration'] ?? $binding['device_generation'] ?? $binding['generation'] ?? $metadata['deviceGeneration'] ?? $metadata['device_generation'] ?? $metadata['generation'] ?? null ); if ($generation !== null) { $metadata['device_generation'] = $generation; $metadata['generation'] = $generation; } if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) { $metadata['last_resolution'] = (array)$binding['last_resolution']; } if (array_key_exists('last_success_at', $binding)) { $metadata['last_success_at'] = $binding['last_success_at']; } if (array_key_exists('last_error', $binding)) { $metadata['last_error'] = $binding['last_error']; } return $metadata; } private function buildDeliveryMetadata(array $overrides = [], int $ttlSeconds = self::COMMAND_EXPIRES_AFTER_SECONDS): array { $preferredChannel = strtoupper(trim((string)($overrides['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); if (!in_array($preferredChannel, [ self::DELIVERY_CHANNEL_BROKER, self::DELIVERY_CHANNEL_API, self::DELIVERY_CHANNEL_CLOUD, ], true)) { $preferredChannel = self::DELIVERY_CHANNEL_API; } return array_merge([ 'preferred_channel' => $preferredChannel, 'delivery_channel' => $overrides['delivery_channel'] ?? null, 'attempt_count' => isset($overrides['attempt_count']) ? (int)$overrides['attempt_count'] : 0, 'expires_at' => $overrides['expires_at'] ?? $this->formatDateTime(time() + max(30, $ttlSeconds)), 'fallback_reason' => $overrides['fallback_reason'] ?? null, 'last_dispatch_error' => $overrides['last_dispatch_error'] ?? null, ], $overrides); } private function resolveRelayExecutionPlan(edge_gateways_o $gateway, array $binding, string $logicalRelayId): array { $gatewayData = $gateway->asArray(); $gatewayData['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); $gatewayData['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); $gatewayData['inventory'] = $this->listInventory((int)$gateway->id); $gatewayData['bindings'] = [$binding]; $runtime = self::deriveGatewayRuntimeState($gatewayData); $relayHealth = (array)($runtime['relay_health'][0] ?? []); $executionPath = (string)($relayHealth['execution_path'] ?? 'local'); return array_merge($relayHealth, [ 'relay_id' => $logicalRelayId, 'execution_path' => $executionPath, 'preferred_channel' => $executionPath === 'local' ? $this->resolveGatewayPreferredCommandChannel($gateway) : self::DELIVERY_CHANNEL_CLOUD, ]); } private function forceLocalRelayExecutionPlan(array $resolution): array { $wasCloud = (string)($resolution['execution_path'] ?? 'local') === 'cloud'; $recoveryActions = array_values(array_filter(array_unique(array_merge( (array)($resolution['recovery_actions'] ?? []), ['retry_local_command'] )))); return array_merge($resolution, [ 'execution_path' => 'local', 'preferred_channel' => self::DELIVERY_CHANNEL_BROKER, 'fallback_mode' => self::RELAY_FALLBACK_LOCAL_ONLY, 'reason' => $wasCloud ? 'local_transport_override' : ($resolution['reason'] ?? null), 'recommended_action' => $resolution['recommended_action'] ?? 'retry_local_command', 'recovery_actions' => $recoveryActions, ]); } private function normalizeRelayToggleAfter(?int $toggleAfterSeconds): ?int { if ($toggleAfterSeconds === null || $toggleAfterSeconds <= 0) { return null; } return $toggleAfterSeconds; } private function currentRelayActionContext(): array { $context = []; foreach (self::$relayActionContextStack as $entry) { if (is_array($entry)) { $context = array_replace_recursive($context, $entry); } } return $context; } private function normalizeRelayActionContext(array $context): array { $context = array_replace_recursive($this->currentRelayActionContext(), $context); $module = trim((string)($context['module'] ?? $context['module_responsible'] ?? '')); $reason = trim((string)($context['reason'] ?? $context['action_reason'] ?? '')); $context['module'] = $module !== '' ? $module : 'edge_gateway'; $context['module_responsible'] = $context['module']; $context['reason'] = $reason !== '' ? $reason : 'Relay dispatch'; $actor = array_replace( $this->resolveRelayAuthenticatedActorContext(), isset($context['actor']) && is_array($context['actor']) ? (array)$context['actor'] : [] ); foreach (['user_id', 'admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id', 'type', 'display_name'] as $key) { if (array_key_exists($key, $context) && !array_key_exists($key, $actor)) { $actor[$key] = $context[$key]; } } $context['actor'] = $this->compactRelayLogArray($actor); $associated = isset($context['associated']) && is_array($context['associated']) ? (array)$context['associated'] : []; foreach (['admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id'] as $key) { if (isset($context['actor'][$key]) && !isset($associated[$key])) { $associated[$key] = $context['actor'][$key]; } if (array_key_exists($key, $context) && !isset($associated[$key])) { $associated[$key] = $context[$key]; } } $context['associated'] = $this->compactRelayLogArray($associated); return $this->compactRelayLogArray($context); } private function resolveRelayAuthenticatedActorContext(): array { if (!function_exists('getallheaders')) { return []; } try { $headers = getallheaders(); } catch (\Throwable) { return []; } if (!is_array($headers) || $this->relayHeaderValue($headers, 'Authorization') === null) { return []; } try { $user = (new authentication())->get_user(); } catch (\Throwable) { return []; } if (!$user instanceof \objects\users_o || !$user->exists()) { return []; } $userId = (int)$user->id; $customerNumber = isset($user->customer_number) ? (int)($user->customer_number->value() ?? 0) : 0; $displayName = isset($user->display_name) ? trim((string)($user->display_name->value() ?? '')) : ''; $actor = [ 'user_id' => $userId, 'type' => $customerNumber > 0 ? 'customer' : 'admin', ]; if ($displayName !== '') { $actor['display_name'] = $displayName; } if ($customerNumber > 0) { $actor['customer_user_id'] = $userId; $actor['customer_number'] = $customerNumber; } else { $actor['admin_user_id'] = $userId; } return $actor; } private function relayHeaderValue(array $headers, string $name): ?string { $normalized = strtolower($name); foreach ($headers as $key => $value) { if (strtolower((string)$key) === $normalized) { $value = trim((string)$value); return $value !== '' ? $value : null; } } return null; } private function resolveRelayRequestedBy(array $actionContext): ?int { $actor = isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : []; foreach (['admin_user_id', 'user_id', 'customer_user_id'] as $key) { $id = isset($actor[$key]) ? (int)$actor[$key] : 0; if ($id > 0) { return $id; } } return null; } private function buildRelayCommandSignal(edge_gateway_command_jobs_o $job, array $request): array { $delivery = (array)($job->delivery_json->value() ?? []); return $this->compactRelayLogArray([ 'command_type' => (string)$job->command_type->value(), 'job_id' => (int)$job->id, 'correlation_id' => (string)$job->correlation_id->value(), 'request' => $request, 'preferred_channel' => $delivery['preferred_channel'] ?? null, 'require_fast_path' => !empty($delivery['require_fast_path']), 'fallback_reason' => $delivery['fallback_reason'] ?? null, ]); } private function appendRelayDispatchLog( array $binding, array $resolution, bool $success, array $dispatchLog, array $result = [], ?\Throwable $exception = null ): ?array { try { $gatewayId = (int)($binding['gateway_id'] ?? 0); if ($gatewayId <= 0) { return null; } $context = $this->buildRelayLogContext($binding, $resolution, $success, $dispatchLog, $result, $exception); return $this->appendGatewayLogEntry( $gatewayId, $this->buildRelayLogMessage($context, $success), $success ? 'INFO' : 'ERROR', 'relay', 'RELAY_DISPATCH', $context ); } catch (\Throwable) { return null; } } private function buildRelayLogContext( array $binding, array $resolution, bool $success, array $dispatchLog, array $result, ?\Throwable $exception ): array { $actionContext = $this->normalizeRelayActionContext( isset($dispatchLog['action_context']) && is_array($dispatchLog['action_context']) ? (array)$dispatchLog['action_context'] : [] ); $handler = strtolower(trim((string)($dispatchLog['handler'] ?? $resolution['execution_path'] ?? 'local'))); $handler = $handler === 'cloud' ? 'cloud' : 'local'; $deliveryChannel = (string)($resolution['delivery_channel'] ?? ($handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); $relayId = trim((string)($dispatchLog['relay_id'] ?? $binding['relay_id'] ?? '')); $signal = isset($dispatchLog['signal']) && is_array($dispatchLog['signal']) ? (array)$dispatchLog['signal'] : []; if ($relayId !== '' && !isset($signal['relay_id'])) { $signal['relay_id'] = $relayId; } $relayRole = $this->normalizeRelayLogRole( $actionContext['relay_role'] ?? $actionContext['role'] ?? $binding['metadata']['relay_role'] ?? $binding['metadata']['role'] ?? null ); $relayName = $this->resolveRelayLogDisplayName($binding, $actionContext, $signal); $context = [ 'success' => $success, 'module' => (string)$actionContext['module'], 'module_responsible' => (string)$actionContext['module_responsible'], 'reason' => (string)$actionContext['reason'], 'handler' => $handler, 'execution_path' => $handler, 'delivery_channel' => $deliveryChannel, 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, 'relay_id' => $relayId !== '' ? $relayId : null, 'relay_name' => $relayName, 'relay_role' => $relayRole, 'action' => strtoupper(trim((string)($dispatchLog['action'] ?? 'RELAY'))), 'target_on' => array_key_exists('target_on', $dispatchLog) ? $dispatchLog['target_on'] : null, 'toggle_after_seconds' => $dispatchLog['toggle_after_seconds'] ?? null, 'actor' => isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : [], 'associated' => isset($actionContext['associated']) && is_array($actionContext['associated']) ? (array)$actionContext['associated'] : [], 'binding' => $this->relayLogBindingPayload($binding), 'execution' => $this->compactRelayLogArray([ 'path' => $handler, 'channel' => $deliveryChannel, 'reason' => $resolution['reason'] ?? null, 'fallback_reason' => $resolution['fallback_reason'] ?? null, 'fallback_mode' => $resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, 'recommended_action' => $resolution['recommended_action'] ?? null, ]), 'signal' => $this->sanitizeRelayLogValue($signal), 'response' => $this->relayLogResponsePayload($result), ]; if ($exception !== null) { $context['error'] = [ 'message' => $exception->getMessage(), 'type' => $exception::class, ]; } $extraContext = $actionContext; unset( $extraContext['module'], $extraContext['module_responsible'], $extraContext['reason'], $extraContext['actor'], $extraContext['associated'], $extraContext['admin_user_id'], $extraContext['customer_user_id'], $extraContext['customer_number'], $extraContext['subuser_id'], $extraContext['relay_name'], $extraContext['relay_label'], $extraContext['relay_role'], $extraContext['role'] ); if ($extraContext !== []) { $context['action_context'] = $this->sanitizeRelayLogValue($extraContext); } $context = $this->compactRelayLogArray($context); $description = $this->buildRelayLogDescription($context, $success); if ($description !== '') { $context['description'] = $description; } return $this->compactRelayLogArray($context); } private function buildRelayLogMessage(array $context, bool $success): string { $description = trim((string)($context['description'] ?? '')); if ($description !== '') { return $description; } return $this->buildRelayLogDescription($context, $success); } private function buildRelayLogDescription(array $context, bool $success): string { $subject = $this->buildRelayLogSubject($context); $handler = strtolower(trim((string)($context['handler'] ?? 'local'))); $channel = trim((string)($context['delivery_channel'] ?? '')); if (!$success) { return trim(sprintf('%s failed via %s', $subject, $handler)); } return trim(sprintf( '%s handled by %s%s', $subject, $handler, $channel !== '' ? ' via ' . $channel : '' )); } private function buildRelayLogSubject(array $context): string { $action = strtoupper(trim((string)($context['action'] ?? 'RELAY'))); $relayId = trim((string)($context['relay_id'] ?? 'unknown')); $relayName = trim((string)($context['relay_name'] ?? '')); $relayTarget = $relayName !== '' ? $relayName : $relayId; $relayRole = $this->normalizeRelayLogRole($context['relay_role'] ?? null); $state = $this->resolveRelayLogState($context); if (in_array($relayRole, ['ENTRY', 'EXIT'], true)) { $verb = $state === false ? 'Close' : 'Open'; return trim(sprintf('%s %s %s', $verb, $relayRole, $relayTarget)); } if (in_array($relayRole, ['MACHINE', 'PROGRAM_PICKER', 'CLEANER'], true) && $state !== null) { return trim(sprintf('%s %s %s', $relayRole, $state ? 'ON' : 'OFF', $relayTarget)); } $targetState = $state !== null ? ($state ? ' ON' : ' OFF') : ''; return trim(sprintf('Relay %s %s%s', $action, $relayTarget, $targetState)); } private function resolveRelayLogState(array $context): ?bool { if (array_key_exists('target_on', $context) && $context['target_on'] !== null) { return (bool)$context['target_on']; } $signal = isset($context['signal']) && is_array($context['signal']) ? (array)$context['signal'] : []; $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; if (array_key_exists('on', $request)) { return (bool)$request['on']; } $response = isset($context['response']) && is_array($context['response']) ? (array)$context['response'] : []; if (array_key_exists('on', $response)) { return (bool)$response['on']; } return null; } private function normalizeRelayLogRole(mixed $role): ?string { $normalized = strtoupper(trim((string)($role ?? ''))); if ($normalized === '') { return null; } return match ($normalized) { 'ENTRANCE', 'IN', 'INLET', 'ENTRY_GATE' => 'ENTRY', 'OUT', 'OUTLET', 'EXIT_GATE' => 'EXIT', 'MACHINE_PROGRAM_PICKER', 'PROGRAM_SELECTOR', 'PICKER' => 'PROGRAM_PICKER', 'MACHINE_CLEANER' => 'CLEANER', default => $normalized, }; } private function resolveRelayLogDisplayName(array $binding, array $actionContext, array $signal): ?string { $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; $relayId = trim((string)($signal['relay_id'] ?? $binding['relay_id'] ?? $request['relayId'] ?? $request['id'] ?? '')); $directName = $this->firstRelayLogString([ $actionContext['relay_name'] ?? null, $actionContext['relay_label'] ?? null, $metadata['relay_name'] ?? null, $metadata['relay_label'] ?? null, $metadata['name'] ?? null, $metadata['label'] ?? null, $request['relay_name'] ?? null, $request['relay_label'] ?? null, ]); if ($directName !== null) { return $directName; } $departmentName = $this->findDepartmentRelayName( isset($binding['department_id']) ? (int)$binding['department_id'] : 0, $relayId ); if ($departmentName !== null) { return $departmentName; } foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { $optionRelayId = trim((string)($option['id'] ?? '')); if ($optionRelayId === '' || $optionRelayId !== $relayId) { continue; } $optionName = $this->firstRelayLogString([ $option['name'] ?? null, $option['label'] ?? null, $option['device_name'] ?? null, ]); if ($optionName !== null) { return $optionName; } } return null; } private function firstRelayLogString(array $candidates): ?string { foreach ($candidates as $candidate) { $value = trim((string)($candidate ?? '')); if ($value !== '') { return $value; } } return null; } private function findDepartmentRelayName(int $departmentId, string $relayId): ?string { if ($departmentId <= 0 || trim($relayId) === '') { return null; } try { $rows = (new department_relays_o())->getFieldsWhere([ 'department' => $departmentId, 'relay_id' => $relayId, 'deleted_at' => null, ], ['id', 'name']); } catch (\Throwable) { return null; } foreach ($rows as $row) { $name = trim((string)($row['name'] ?? '')); if ($name !== '') { return $name; } } return null; } private function relayLogBindingPayload(array $binding): array { $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; return $this->compactRelayLogArray([ 'id' => isset($binding['id']) ? (int)$binding['id'] : null, 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, 'relay_id' => $binding['relay_id'] ?? null, 'relay_name' => $this->firstRelayLogString([ $metadata['relay_name'] ?? null, $metadata['relay_label'] ?? null, $metadata['name'] ?? null, $metadata['label'] ?? null, ]), 'device_id' => $binding['device_id'] ?? null, 'local_ip' => $binding['local_ip'] ?? null, 'channel' => isset($binding['channel']) ? (int)$binding['channel'] : null, ]); } private function relayLogResponsePayload(array $result): array { if ($result === []) { return []; } return $this->compactRelayLogArray([ 'online' => array_key_exists('online', $result) ? (bool)$result['online'] : null, 'on' => array_key_exists('on', $result) ? (bool)$result['on'] : null, 'raw' => $this->sanitizeRelayLogValue($result['raw'] ?? $result), ]); } private function normalizeRelayTransportResponse(array|object|null $response): array { if ($response === null) { return []; } $normalized = is_array($response) ? (array)($response[0] ?? $response) : (array)$response; return [ 'online' => (bool)($normalized['online'] ?? true), 'on' => (bool)($normalized['on'] ?? $normalized['output'] ?? $normalized['status']['switch:0']['output'] ?? false), 'raw' => (array)($normalized['raw'] ?? $normalized), ]; } private function inferRelayIdFromTransportPayload(array $payload): ?string { $relayId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? '')); if ($relayId !== '') { return $relayId; } $ids = (array)($payload['ids'] ?? []); foreach ($ids as $id) { $relayId = trim((string)$id); if ($relayId !== '') { return $relayId; } } return null; } private function inferRelayActionFromEndpoint(string $endpoint): string { return str_contains(strtolower($endpoint), '/get') ? 'STATUS' : 'SWITCH'; } private function compactRelayLogArray(array $value): array { return array_filter( $value, static fn(mixed $entry): bool => $entry !== null && $entry !== '' && $entry !== [] ); } private function sanitizeRelayLogValue(mixed $value, int $depth = 0): mixed { if ($depth > 5) { return '[truncated]'; } if (is_object($value)) { $value = (array)$value; } if (is_array($value)) { $result = []; $count = 0; foreach ($value as $key => $entry) { if ($count >= 80) { $result['__truncated'] = true; break; } $result[$key] = $this->sanitizeRelayLogValue($entry, $depth + 1); $count++; } return $result; } if (is_string($value) && strlen($value) > 2000) { return substr($value, 0, 2000) . '...'; } if (is_scalar($value) || $value === null) { return $value; } return (string)$value; } /** * @throws Exception */ private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array { $delivery = (array)($job->delivery_json->value() ?? []); $preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); $requireFastPath = !empty($delivery['require_fast_path']); $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if (!$requireFastPath && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Gateway agent is offline'); } if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER) { if ($this->buildBrokerInternalUrl() === null) { $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, 'Edge broker is not configured', 'broker_not_configured'); if ($requireFastPath) { $this->finalizeCommandJob($job, false, [], 'Edge broker is not configured', $gateway); throw new Exception('Edge broker is not configured'); } } else { try { $this->markCommandJobDispatching($job, self::DELIVERY_CHANNEL_BROKER); $payload = $this->dispatchBrokerCommand($gateway, $job); $this->finalizeCommandJob($job, true, $payload, null, $gateway); return $payload; } catch (Exception $exception) { $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, $exception->getMessage(), 'broker_dispatch_failed'); if ($requireFastPath) { $this->finalizeCommandJob( $job, false, [], $exception->getMessage(), $gateway, $this->isCommandTimeoutError($exception->getMessage()) ? 'TIMED_OUT' : 'FAILED' ); throw $exception; } } } } if ($requireFastPath) { $this->finalizeCommandJob($job, false, [], 'Edge broker fast path is unavailable', $gateway); throw new Exception('Edge broker fast path is unavailable'); } return $this->waitForCommandResult((int)$job->id); } private function isCommandTimeoutError(string $errorMessage): bool { return str_contains(strtolower(trim($errorMessage)), 'timed out'); } private function markCommandJobDispatching( edge_gateway_command_jobs_o $job, string $channel, ?string $fallbackReason = null ): void { $delivery = $this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => $channel, 'attempt_count' => (int)((array)($job->delivery_json->value() ?? [])['attempt_count'] ?? 0) + 1, 'fallback_reason' => $fallbackReason, 'last_dispatch_error' => null, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS ); $job->status->set('DISPATCHING'); $job->response_json->set([]); $job->completed_at->set(null); $job->error_message->set(null); $job->delivery_json->set($delivery); } private function markCommandDeliveryFailure( edge_gateway_command_jobs_o $job, string $channel, string $errorMessage, ?string $fallbackReason = null ): void { $delivery = $this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => $channel, 'fallback_reason' => $fallbackReason, 'last_dispatch_error' => $errorMessage, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS ); $job->delivery_json->set($delivery); } /** * @throws Exception */ private function dispatchBrokerCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array { $brokerUrl = $this->buildBrokerInternalUrl(); if ($brokerUrl === null) { throw new Exception('Edge broker is not configured'); } $result = $this->httpJsonRequest( $brokerUrl . '/api/gateways/' . (int)$gateway->id . '/commands', [ 'commandType' => (string)$job->command_type->value(), 'payload' => $this->buildCommandExecutionPayload($job, $gateway), ], [ 'x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret(), ], self::BROKER_HTTP_TIMEOUT_SECONDS ); if (!is_array($result) || empty($result['ok'])) { throw new Exception(trim((string)($result['error'] ?? 'Edge broker dispatch failed')) ?: 'Edge broker dispatch failed'); } return isset($result['payload']) && is_array($result['payload']) ? (array)$result['payload'] : []; } /** * @throws Exception */ private function dispatchRelayThroughCloud( int $departmentId, string $logicalRelayId, ?bool $on, array $binding, array $resolution, ?int $toggleAfterSeconds = null, array $actionContext = [] ): array { $transport = new cloud_shelly_transport(null, false); $setPayload = ['id' => $logicalRelayId, 'on' => $on]; if ($on !== null && $toggleAfterSeconds !== null) { $setPayload['toggle_after'] = $toggleAfterSeconds; } $endpoint = $on === null ? '/v2/devices/api/get' : '/v2/devices/api/set/switch'; $request = $on === null ? ['ids' => [$logicalRelayId]] : $setPayload; $actionContext = $this->normalizeRelayActionContext($actionContext); $dispatchLog = [ 'action' => $on === null ? 'STATUS' : 'SWITCH', 'handler' => 'cloud', 'relay_id' => $logicalRelayId, 'target_on' => $on, 'toggle_after_seconds' => $toggleAfterSeconds, 'signal' => [ 'endpoint' => $endpoint, 'request' => $request, ], 'action_context' => $actionContext, ]; try { $response = $transport->sendPostRequest($endpoint, $request, $departmentId); } catch (\Throwable $exception) { $this->appendRelayDispatchLog( $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, ]), false, $dispatchLog, [], $exception ); throw $exception; } $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response; $result = [ 'online' => (bool)($normalized['online'] ?? true), 'on' => (bool)($normalized['on'] ?? $normalized['output'] ?? $normalized['status']['switch:0']['output'] ?? $on ?? false), 'raw' => (array)($normalized['raw'] ?? $normalized), ]; return $this->finalizeRelayDispatch( $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, ]), $result, $dispatchLog ); } /** * @throws Exception */ private function handleRelayDispatchFailure( int $departmentId, string $logicalRelayId, array $binding, array $resolution, ?bool $on, Exception $exception, ?int $toggleAfterSeconds = null, array $actionContext = [] ): array { $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage()); $this->recordRelayBindingResolution( $binding, array_merge($resolution, [ 'execution_path' => 'local', 'delivery_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'reason' => 'local_dispatch_failed', 'recommended_action' => $recommendedAction, 'recovery_actions' => [$recommendedAction], ]), false, $exception->getMessage() ); if ((string)($resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL) !== self::RELAY_FALLBACK_PREFER_LOCAL) { throw $exception; } try { return $this->dispatchRelayThroughCloud( $departmentId, $logicalRelayId, $on, $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'reason' => 'local_dispatch_failed', 'fallback_reason' => $exception->getMessage(), 'recommended_action' => $recommendedAction, 'recovery_actions' => [$recommendedAction, 'force_cloud'], ]), $toggleAfterSeconds, $actionContext ); } catch (Exception $cloudException) { $this->recordRelayBindingResolution( $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, 'reason' => 'cloud_fallback_failed', 'recommended_action' => $recommendedAction, ]), false, $cloudException->getMessage() ); throw $cloudException; } } private function finalizeRelayDispatch(array $binding, array $resolution, array $result, array $dispatchLog = []): array { $executionPath = (string)($resolution['execution_path'] ?? 'local'); $deliveryChannel = $executionPath === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); $resolutionPayload = array_merge($resolution, [ 'delivery_channel' => $deliveryChannel, 'execution_path' => $executionPath, ]); $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null); if ($dispatchLog !== []) { $this->appendRelayDispatchLog($binding, $resolutionPayload, true, $dispatchLog, $result, null); } return array_merge($result, [ 'binding' => $this->reloadRelayBinding((int)$binding['id']), 'execution' => [ 'path' => $executionPath, 'channel' => $deliveryChannel, 'reason' => $resolutionPayload['reason'] ?? null, 'fallback_mode' => $resolutionPayload['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, 'recommended_action' => $resolutionPayload['recommended_action'] ?? null, ], 'raw' => (array)($result['raw'] ?? []), ]); } private function reloadRelayBinding(int $bindingId): array { $binding = (new edge_gateway_relay_bindings_o())->select($bindingId); return $binding->exists() ? $binding->asArray() : []; } private function recordRelayBindingResolution( array $binding, array $resolution, bool $success, ?string $errorMessage ): void { $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$binding['id']); if (!$bindingObject->exists()) { return; } $metadata = $this->normalizeRelayBindingMetadata((array)($bindingObject->metadata_json->value() ?? []), $binding); $metadata['last_resolution'] = [ 'at' => $this->now(), 'execution_path' => $resolution['execution_path'] ?? 'local', 'delivery_channel' => $resolution['delivery_channel'] ?? ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'reason' => $resolution['reason'] ?? null, 'fallback_mode' => $metadata['fallback_mode'], 'recommended_action' => $resolution['recommended_action'] ?? null, 'recovery_actions' => array_values(array_filter((array)($resolution['recovery_actions'] ?? []))), 'gateway_status' => $resolution['gateway_status'] ?? null, 'device_online' => $resolution['device_online'] ?? null, 'device_freshness_seconds' => $resolution['device_freshness_seconds'] ?? null, 'device_freshness_state' => $resolution['device_freshness_state'] ?? null, ]; if ($success) { $metadata['last_success_at'] = $this->now(); $metadata['last_error'] = null; } else { $metadata['last_error'] = $errorMessage; } $bindingObject->metadata_json->set($metadata); } private function mapRelayFailureToRecommendedAction(string $errorMessage): string { $normalized = strtolower(trim($errorMessage)); if ($normalized === '') { return 'retry_local_command'; } if (str_contains($normalized, 'credential') || str_contains($normalized, 'token')) { return 'rotate_credentials'; } if (str_contains($normalized, 'discovery') || str_contains($normalized, 'device')) { return 'retry_discovery'; } if (str_contains($normalized, 'update')) { return 'retry_update'; } if (str_contains($normalized, 'offline') || str_contains($normalized, 'timeout') || str_contains($normalized, 'broker')) { return 'restart_agent'; } return 'retry_local_command'; } /** * @throws Exception */ private function httpJsonRequest(string $url, array $payload, array $headers = [], int $timeoutSeconds = 5): array { $defaultHeaders = [ 'Content-Type: application/json', 'Accept: application/json', ]; $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", array_filter(array_merge($defaultHeaders, $headers))), 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'timeout' => max(1, $timeoutSeconds), 'ignore_errors' => true, ], ]); $response = @file_get_contents($url, false, $context); if ($response === false) { throw new Exception('Unable to reach edge broker'); } $decoded = json_decode($response, true); if (!is_array($decoded)) { throw new Exception('Edge broker returned an invalid response'); } $statusLine = is_array($http_response_header ?? null) ? (string)($http_response_header[0] ?? '') : ''; if ($statusLine !== '' && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) { $statusCode = (int)$matches[1]; if ($statusCode >= 400) { throw new Exception(trim((string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')) ?: 'Edge broker request failed'); } } return $decoded; } /** * @param array $gatewayIds * @return array */ private function aggregateInventoryUsage(array $gatewayIds): array { if ($gatewayIds === []) { return self::emptyInventoryUsage(); } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total FROM edge_gateway_device_inventory WHERE deleted_at IS NULL AND gateway_id IN ($placeholders)" ); $statement->execute($gatewayIds); $row = $statement->fetch(); return [ 'total' => (int)($row['total'] ?? 0), 'online' => (int)($row['online_total'] ?? 0), 'offline' => (int)($row['offline_total'] ?? 0), ]; } /** * @param array $gatewayIds * @return array */ private function aggregateBindingUsage(array $gatewayIds): array { if ($gatewayIds === []) { return self::emptyBindingUsage(); } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total FROM edge_gateway_relay_bindings WHERE deleted_at IS NULL AND gateway_id IN ($placeholders)" ); $statement->execute(array_merge([ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_CLOUD_ONLY, self::RELAY_FALLBACK_LOCAL_ONLY, ], $gatewayIds)); $row = $statement->fetch(); return [ 'total' => (int)($row['total'] ?? 0), 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), 'local_only' => (int)($row['local_only_total'] ?? 0), ]; } /** * @param array $gatewayIds * @return array> */ private function aggregateInventoryUsageByGateway(array $gatewayIds): array { if ($gatewayIds === []) { return []; } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT gateway_id, COUNT(*) AS total, SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total FROM edge_gateway_device_inventory WHERE deleted_at IS NULL AND gateway_id IN ($placeholders) GROUP BY gateway_id" ); $statement->execute($gatewayIds); $rows = []; while (($row = $statement->fetch()) !== false) { if (!is_array($row) || !isset($row['gateway_id'])) { continue; } $rows[(int)$row['gateway_id']] = [ 'total' => (int)($row['total'] ?? 0), 'online' => (int)($row['online_total'] ?? 0), 'offline' => (int)($row['offline_total'] ?? 0), ]; } return $rows; } /** * @param array $gatewayIds * @return array> */ private function aggregateBindingUsageByGateway(array $gatewayIds): array { if ($gatewayIds === []) { return []; } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT gateway_id, COUNT(*) AS total, SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total FROM edge_gateway_relay_bindings WHERE deleted_at IS NULL AND gateway_id IN ($placeholders) GROUP BY gateway_id" ); $statement->execute(array_merge([ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_CLOUD_ONLY, self::RELAY_FALLBACK_LOCAL_ONLY, ], $gatewayIds)); $rows = []; while (($row = $statement->fetch()) !== false) { if (!is_array($row) || !isset($row['gateway_id'])) { continue; } $rows[(int)$row['gateway_id']] = [ 'total' => (int)($row['total'] ?? 0), 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), 'local_only' => (int)($row['local_only_total'] ?? 0), ]; } return $rows; } /** * @param array> $gateways * @param array> $inventoryUsageByGateway * @param array> $bindingUsageByGateway * @return array> */ private static function attachGatewayCollectionSummaries( array $gateways, array $inventoryUsageByGateway = [], array $bindingUsageByGateway = [] ): array { foreach ($gateways as $index => $gateway) { if (!is_array($gateway)) { continue; } $gatewayId = (int)($gateway['id'] ?? 0); $gateways[$index] = self::decorateGatewayUsageSummaries( $gateway, $inventoryUsageByGateway[$gatewayId] ?? null, $bindingUsageByGateway[$gatewayId] ?? null ); } return $gateways; } /** * @param array> $gateways * @return array */ private static function aggregateInventoryUsageFromGatewayRows(array $gateways): array { $totals = self::emptyInventoryUsage(); foreach ($gateways as $gateway) { if (!is_array($gateway)) { continue; } $summary = self::resolveInventoryUsageForGateway($gateway); $totals['total'] += (int)($summary['total'] ?? 0); $totals['online'] += (int)($summary['online'] ?? 0); $totals['offline'] += (int)($summary['offline'] ?? 0); } return $totals; } /** * @param array> $gateways * @return array */ private static function aggregateBindingUsageFromGatewayRows(array $gateways): array { $totals = self::emptyBindingUsage(); foreach ($gateways as $gateway) { if (!is_array($gateway)) { continue; } $summary = self::resolveBindingUsageForGateway($gateway); $totals['total'] += (int)($summary['total'] ?? 0); $totals['fallback_overrides'] += (int)($summary['fallback_overrides'] ?? 0); $totals['cloud_only'] += (int)($summary['cloud_only'] ?? 0); $totals['local_only'] += (int)($summary['local_only'] ?? 0); } return $totals; } /** * @param array $gateway * @param array|null $inventoryUsage * @param array|null $bindingUsage * @return array */ private static function decorateGatewayUsageSummaries( array $gateway, ?array $inventoryUsage = null, ?array $bindingUsage = null ): array { $inventory = self::resolveInventoryUsageForGateway($gateway, $inventoryUsage); $bindings = self::resolveBindingUsageForGateway($gateway, $bindingUsage); $gateway['inventory_summary'] = $inventory; $gateway['binding_summary'] = [ 'total' => (int)($bindings['total'] ?? 0), 'fallback_overrides' => (int)($bindings['fallback_overrides'] ?? 0), ]; $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) ? (array)$gateway['fallback_summary'] : []; $gateway['fallback_summary'] = array_merge($fallbackSummary, [ 'cloud_only_relays' => (int)($fallbackSummary['cloud_only_relays'] ?? $bindings['cloud_only'] ?? 0), 'local_only_relays' => (int)($fallbackSummary['local_only_relays'] ?? $bindings['local_only'] ?? 0), ]); return $gateway; } /** * @param array $gateway * @param array|null $summary * @return array */ private static function resolveInventoryUsageForGateway(array $gateway, ?array $summary = null): array { if ($summary !== null) { return array_merge(self::emptyInventoryUsage(), $summary); } if (isset($gateway['inventory_summary']) && is_array($gateway['inventory_summary'])) { return array_merge(self::emptyInventoryUsage(), array_map('intval', $gateway['inventory_summary'])); } $inventory = isset($gateway['inventory']) && is_array($gateway['inventory']) ? $gateway['inventory'] : []; $online = 0; $offline = 0; foreach ($inventory as $device) { if (!is_array($device)) { continue; } if (($device['online'] ?? true) === false) { $offline += 1; continue; } $online += 1; } return [ 'total' => count($inventory), 'online' => $online, 'offline' => $offline, ]; } /** * @param array $gateway * @param array|null $summary * @return array */ private static function resolveBindingUsageForGateway(array $gateway, ?array $summary = null): array { if ($summary !== null) { return array_merge(self::emptyBindingUsage(), $summary); } if (isset($gateway['binding_summary']) && is_array($gateway['binding_summary'])) { $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) ? (array)$gateway['fallback_summary'] : []; return array_merge(self::emptyBindingUsage(), [ 'total' => (int)($gateway['binding_summary']['total'] ?? 0), 'fallback_overrides' => (int)($gateway['binding_summary']['fallback_overrides'] ?? 0), 'cloud_only' => (int)($fallbackSummary['cloud_only_relays'] ?? 0), 'local_only' => (int)($fallbackSummary['local_only_relays'] ?? 0), ]); } $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) ? $gateway['bindings'] : []; $fallbackOverrides = 0; $cloudOnly = 0; $localOnly = 0; foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $fallbackMode = isset($metadata['fallback_mode']) ? (string)$metadata['fallback_mode'] : (string)($binding['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); $fallbackMode = self::normalizeFallbackMode($fallbackMode); if ($fallbackMode !== self::RELAY_FALLBACK_PREFER_LOCAL) { $fallbackOverrides += 1; } if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { $cloudOnly += 1; } if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { $localOnly += 1; } } return [ 'total' => count($bindings), 'fallback_overrides' => $fallbackOverrides, 'cloud_only' => $cloudOnly, 'local_only' => $localOnly, ]; } /** * @return array */ private static function emptyInventoryUsage(): array { return [ 'total' => 0, 'online' => 0, 'offline' => 0, ]; } /** * @return array */ private static function emptyBindingUsage(): array { return [ 'total' => 0, 'fallback_overrides' => 0, 'cloud_only' => 0, 'local_only' => 0, ]; } /** * @param array $values */ private static function appendNumericMetric(array &$values, mixed $value): void { if (!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) { return; } $values[] = (float)$value; } /** * @param array $values */ private static function averageMetric(array $values): ?int { if ($values === []) { return null; } return (int)round(array_sum($values) / count($values)); } public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array { $effectiveStatus = self::resolveGatewayStatus( isset($gateway['status']) ? (string)$gateway['status'] : null, isset($gateway['last_heartbeat_at']) && $gateway['last_heartbeat_at'] !== null ? (string)$gateway['last_heartbeat_at'] : null, $now ); $gateway['status'] = $effectiveStatus; $gateway['discovery_status'] = self::resolveDiscoveryStatus( isset($gateway['discovery_status']) ? (string)$gateway['discovery_status'] : null, $effectiveStatus ); $gateway['metadata'] = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $gateway['operational_snapshot'] = isset($gateway['operational_snapshot']) && is_array($gateway['operational_snapshot']) ? (array)$gateway['operational_snapshot'] : []; $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now); $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now); $fallbackSummary = self::buildFallbackSummary($relayHealth); $gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now); $lastSyncAt = self::resolveLastSyncAt($gateway); $gateway['channel_status'] = $channelStatus; $gateway['relay_health'] = $relayHealth; $gateway['fallback_summary'] = $fallbackSummary; $gateway['transport_health'] = self::deriveTransportHealth( $gateway, $effectiveStatus, $channelStatus, $fallbackSummary, $lastSyncAt ); $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY'); $gateway['last_successful_operation_at'] = $gateway['operational_snapshot']['last_successful_operation_at'] ?? null; $gateway['backlog_depth'] = [ 'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0), 'operations' => (int)($gateway['operational_snapshot']['operation_backlog'] ?? 0), ]; $gateway['version_drift'] = self::buildVersionDriftSummary($gateway); $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now); $gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus); $gateway['last_sync_at'] = $lastSyncAt; $gateway['update_window'] = self::buildUpdateWindowSummary($gateway); $gateway['staged_version'] = self::buildStagedVersionSummary($gateway); $gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway); $gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now); $gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway); return $gateway; } private static function deriveChannelStatus(array $gateway, string $effectiveStatus, ?int $now = null): array { $metadata = (array)($gateway['metadata'] ?? []); $operational = (array)($gateway['operational_snapshot'] ?? []); $brokerPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) ? (array)$metadata['broker_presence'] : []; $brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null; $brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $now); $brokerConnected = self::isBrokerPresenceConnected($brokerPresence, $now); $brokerHealthy = $brokerConnected && $brokerAgeSeconds !== null && $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS; $commandPreferred = $brokerConnected ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; return [ 'command' => [ 'preferred' => $commandPreferred, 'active' => $brokerHealthy ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API, 'state' => $effectiveStatus === self::STATUS_OFFLINE ? self::STATUS_OFFLINE : ($brokerConnected ? ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED) : self::STATUS_DEGRADED), 'backlog_depth' => (int)($operational['command_backlog'] ?? 0), 'last_success_at' => $operational['last_successful_command_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null), ], 'broker' => [ 'connected' => $brokerConnected, 'state' => !$brokerConnected ? self::STATUS_OFFLINE : ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED), 'last_seen_at' => $brokerLastSeenAt, 'disconnect_reason' => isset($brokerPresence['disconnect_reason']) ? (string)$brokerPresence['disconnect_reason'] : null, 'last_error' => isset($brokerPresence['last_error']) ? (string)$brokerPresence['last_error'] : null, ], ]; } private static function isBrokerPresenceConnected(array $presence, ?int $now = null): bool { if (empty($presence['connected'])) { return false; } $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null; $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt, $now); return $ageSeconds !== null && $ageSeconds < self::BROKER_PRESENCE_TTL_SECONDS; } private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array { $bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : []; $inventory = is_array($gateway['inventory'] ?? null) ? (array)$gateway['inventory'] : []; $inventoryByDeviceId = []; foreach ($inventory as $device) { if (!is_array($device)) { continue; } $deviceId = trim((string)($device['device_id'] ?? '')); if ($deviceId !== '') { $inventoryByDeviceId[$deviceId] = $device; } } $departmentTransportMode = (string)($gateway['department_transport_mode'] ?? self::TRANSPORT_MODE_CLOUD); $relayHealth = []; foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $bindingMetadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $fallbackMode = self::normalizeFallbackMode((string)($binding['fallback_mode'] ?? $bindingMetadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL)); $device = isset($inventoryByDeviceId[(string)($binding['device_id'] ?? '')]) ? (array)$inventoryByDeviceId[(string)$binding['device_id']] : null; $deviceLastSeenAt = is_array($device) && isset($device['last_seen_at']) ? (string)$device['last_seen_at'] : null; $deviceFreshnessSeconds = self::heartbeatAgeSeconds($deviceLastSeenAt, $now); $deviceOnline = is_array($device) && array_key_exists('online', $device) ? (bool)$device['online'] : null; $deviceFresh = $device !== null && $deviceOnline !== false && $deviceFreshnessSeconds !== null && $deviceFreshnessSeconds < self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS; $executionPath = 'local'; $reason = null; if ($departmentTransportMode === self::TRANSPORT_MODE_CLOUD) { $executionPath = 'cloud'; $reason = 'department_cutover'; } elseif ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { $executionPath = 'cloud'; $reason = 'binding_cloud_only'; } elseif ($effectiveStatus === self::STATUS_OFFLINE) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = 'gateway_offline'; } elseif ($device === null) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = 'device_missing'; } elseif (!$deviceFresh) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = $deviceOnline === false ? 'device_offline' : 'device_stale'; } $recommendedAction = match ($reason) { 'department_cutover' => 'review_department_cutover', 'binding_cloud_only' => 'review_binding_override', 'gateway_offline' => 'restart_agent', 'device_missing', 'device_stale', 'device_offline' => 'retry_discovery', default => null, }; $relayHealth[] = [ 'binding_id' => isset($binding['id']) ? (int)$binding['id'] : null, 'relay_id' => isset($binding['relay_id']) ? (string)$binding['relay_id'] : null, 'device_id' => isset($binding['device_id']) ? (string)$binding['device_id'] : null, 'fallback_mode' => $fallbackMode, 'execution_path' => $executionPath, 'reason' => $reason, 'recommended_action' => $recommendedAction, 'recovery_actions' => array_values(array_filter([$recommendedAction])), 'device_online' => $deviceOnline, 'device_freshness_seconds' => $deviceFreshnessSeconds, 'device_freshness_state' => self::resolveDeviceFreshnessState($device, $deviceFreshnessSeconds), 'gateway_status' => $effectiveStatus, 'last_resolution' => isset($binding['last_resolution']) && is_array($binding['last_resolution']) ? (array)$binding['last_resolution'] : (isset($bindingMetadata['last_resolution']) && is_array($bindingMetadata['last_resolution']) ? (array)$bindingMetadata['last_resolution'] : null), 'last_success_at' => $binding['last_success_at'] ?? $bindingMetadata['last_success_at'] ?? null, 'last_error' => $binding['last_error'] ?? $bindingMetadata['last_error'] ?? null, ]; } return $relayHealth; } private static function buildFallbackSummary(array $relayHealth): array { $summary = [ 'local_relays' => 0, 'cloud_relays' => 0, 'local_only_relays' => 0, 'cloud_only_relays' => 0, 'affected_relays' => [], 'recommended_action' => null, ]; foreach ($relayHealth as $relay) { $executionPath = (string)($relay['execution_path'] ?? 'local'); if ($executionPath === 'cloud') { $summary['cloud_relays'] += 1; if (!empty($relay['relay_id'])) { $summary['affected_relays'][] = (string)$relay['relay_id']; } if ($summary['recommended_action'] === null && !empty($relay['recommended_action'])) { $summary['recommended_action'] = (string)$relay['recommended_action']; } } else { $summary['local_relays'] += 1; } $fallbackMode = (string)($relay['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { $summary['local_only_relays'] += 1; } if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { $summary['cloud_only_relays'] += 1; } } return $summary; } private static function deriveTransportHealth( array $gateway, string $effectiveStatus, array $channelStatus, array $fallbackSummary, ?string $lastSyncAt ): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status']) ? (array)$metadata['control_plane_status'] : []; $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE); $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? [])); $transportState = $effectiveStatus; if ($effectiveStatus !== self::STATUS_OFFLINE && ($brokerState === self::STATUS_DEGRADED || $affectedRelayCount > 0)) { $transportState = self::STATUS_DEGRADED; } return [ 'status' => $transportState, 'broker_connected' => !empty($channelStatus['broker']['connected']), 'affected_relay_count' => $affectedRelayCount, 'summary' => $affectedRelayCount > 0 ? $affectedRelayCount . ' relæ(er) kører via cloud fallback' : (!empty($channelStatus['broker']['connected']) ? 'Broker fast path er aktiv med API polling som fallback' : 'API polling er aktiv som primær kontrolkanal'), 'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null), 'last_successful_sync_at' => $lastSyncAt, 'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at']) ? (string)$controlPlaneStatus['last_transport_failure_at'] : null, 'last_transport_error' => isset($controlPlaneStatus['last_transport_error']) ? (string)$controlPlaneStatus['last_transport_error'] : null, ]; } private static function buildVersionDriftSummary(array $gateway): array { $installed = isset($gateway['installed_version']) ? trim((string)$gateway['installed_version']) : ''; $target = isset($gateway['target_version']) ? trim((string)$gateway['target_version']) : ''; $isDrifted = $installed !== '' && $target !== '' && $installed !== $target; return [ 'installed_version' => $installed !== '' ? $installed : null, 'target_version' => $target !== '' ? $target : null, 'release_channel' => isset($gateway['release_channel']) ? (string)$gateway['release_channel'] : self::configuredDefaultReleaseChannel(), 'is_drifted' => $isDrifted, 'status' => $isDrifted ? 'UPDATE_AVAILABLE' : (($installed === '' || $target === '') ? 'UNKNOWN' : 'IN_SYNC'), 'last_successful_update_at' => $gateway['operational_snapshot']['last_successful_update_at'] ?? null, ]; } private static function buildCredentialFreshnessSummary(array $gateway, ?int $now = null): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $rotatedAt = isset($metadata['credentials_rotated_at']) ? (string)$metadata['credentials_rotated_at'] : null; $ageSeconds = self::heartbeatAgeSeconds($rotatedAt, $now); return [ 'rotated_at' => $rotatedAt, 'age_days' => $ageSeconds === null ? null : (int)floor($ageSeconds / 86400), 'state' => $rotatedAt === null ? 'UNKNOWN' : ($ageSeconds !== null && $ageSeconds <= self::CREDENTIAL_FRESH_AFTER_SECONDS ? 'FRESH' : 'STALE'), ]; } /** * @return array> */ private static function defaultGatewayServiceHealth(string $defaultStatus): array { return [ ['name' => 'edge-agent', 'status' => $defaultStatus], ['name' => 'lan-worker', 'status' => $defaultStatus], ['name' => 'redis', 'status' => $defaultStatus], ['name' => 'mariadb', 'status' => $defaultStatus], ['name' => 'minio', 'status' => $defaultStatus], ['name' => 'auto-updater', 'status' => $defaultStatus], ]; } private static function buildContainerHealthSummary(array $gateway, string $effectiveStatus): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $raw = isset($metadata['container_health']) && is_array($metadata['container_health']) ? (array)$metadata['container_health'] : []; $rawServices = isset($raw['services']) && is_array($raw['services']) ? (array)$raw['services'] : []; $defaultServices = self::defaultGatewayServiceHealth( $effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy' ); $services = $defaultServices; foreach ($rawServices as $rawService) { if (!is_array($rawService)) { continue; } $serviceName = trim((string)($rawService['name'] ?? '')); if ($serviceName === '') { continue; } $matched = false; foreach ($services as $index => $defaultService) { if ((string)($defaultService['name'] ?? '') !== $serviceName) { continue; } $services[$index] = array_merge($defaultService, $rawService); $matched = true; break; } if (!$matched) { $services[] = $rawService; } } $healthyCount = 0; $degradedCount = 0; foreach ($services as $index => $service) { if (!is_array($service)) { $services[$index] = ['name' => 'service-' . $index, 'status' => 'unknown']; continue; } $status = strtolower(trim((string)($service['status'] ?? 'unknown'))); $name = trim((string)($service['name'] ?? 'service-' . $index)); if (in_array($status, ['healthy', 'running', 'online'], true)) { $status = 'healthy'; $healthyCount += 1; } elseif (in_array($status, ['degraded', 'starting', 'unknown'], true)) { $status = 'degraded'; $degradedCount += 1; } else { $status = $status === 'offline' ? 'offline' : 'degraded'; $degradedCount += 1; } $services[$index] = array_merge($service, [ 'name' => $name, 'status' => $status, ]); } $state = $effectiveStatus === self::STATUS_OFFLINE ? self::STATUS_OFFLINE : ($degradedCount > 0 ? self::STATUS_DEGRADED : self::STATUS_ONLINE); return [ 'state' => strtoupper((string)($raw['state'] ?? $state)), 'summary' => (string)($raw['summary'] ?? sprintf('%d/%d containers healthy', $healthyCount, count($services))), 'services' => $services, 'healthy_count' => $healthyCount, 'total' => count($services), ]; } private static function buildOutboxStatusSummary(array $gateway, ?int $now = null): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $raw = isset($metadata['outbox_status']) && is_array($metadata['outbox_status']) ? (array)$metadata['outbox_status'] : []; $queued = max(0, (int)($raw['queued'] ?? $raw['queue_depth'] ?? 0)); $oldestQueuedAt = isset($raw['oldest_queued_at']) ? (string)$raw['oldest_queued_at'] : null; $oldestAgeSeconds = self::heartbeatAgeSeconds($oldestQueuedAt, $now); $state = $queued === 0 ? 'IN_SYNC' : (($oldestAgeSeconds !== null && $oldestAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) ? 'DEGRADED' : 'QUEUED'); return [ 'state' => (string)($raw['state'] ?? $state), 'queued' => $queued, 'oldest_queued_at' => $oldestQueuedAt, 'oldest_age_seconds' => $oldestAgeSeconds, 'last_replayed_at' => isset($raw['last_replayed_at']) ? (string)$raw['last_replayed_at'] : null, 'summary' => (string)($raw['summary'] ?? ($queued === 0 ? 'Outbox is empty' : sprintf('%d outbound items queued', $queued))), ]; } private static function resolveLastSyncAt(array $gateway): ?string { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status']) ? (array)$metadata['control_plane_status'] : []; $outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status']) ? (array)$gateway['outbox_status'] : []; $outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status']) ? (array)$metadata['outbox_status'] : []; return self::latestTimestamp([ $controlPlaneStatus['last_successful_sync_at'] ?? null, $metadata['last_sync_at'] ?? null, $outbox['last_replayed_at'] ?? null, $outboxMetadata['last_replayed_at'] ?? null, $gateway['last_heartbeat_at'] ?? null, ]); } private static function buildUpdateWindowSummary(array $gateway): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $window = trim((string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow())); if ($window === '') { $window = self::configuredDefaultUpdateWindow(); } return [ 'window' => $window, 'timezone' => isset($metadata['timezone']) ? (string)$metadata['timezone'] : null, 'strategy' => 'nightly', ]; } private static function buildStagedVersionSummary(array $gateway): ?array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $raw = isset($metadata['staged_version']) && is_array($metadata['staged_version']) ? (array)$metadata['staged_version'] : []; $targetVersion = trim((string)($raw['target_version'] ?? $raw['version'] ?? '')); if ($targetVersion === '') { return null; } return [ 'target_version' => $targetVersion, 'staged_at' => isset($raw['staged_at']) ? (string)$raw['staged_at'] : null, 'apply_after' => isset($raw['apply_after']) ? (string)$raw['apply_after'] : null, 'status' => isset($raw['status']) ? (string)$raw['status'] : 'STAGED', ]; } private static function buildRollbackStatusSummary(array $gateway): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $raw = isset($metadata['rollback_status']) && is_array($metadata['rollback_status']) ? (array)$metadata['rollback_status'] : []; $state = trim((string)($raw['state'] ?? 'IDLE')); return [ 'state' => $state !== '' ? $state : 'IDLE', 'reason' => isset($raw['reason']) ? (string)$raw['reason'] : null, 'rolled_back_to' => isset($raw['rolled_back_to']) ? (string)$raw['rolled_back_to'] : null, 'at' => isset($raw['at']) ? (string)$raw['at'] : null, ]; } private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array { $diagnostics = []; $heartbeatAge = self::heartbeatAgeSeconds( isset($gateway['last_heartbeat_at']) ? (string)$gateway['last_heartbeat_at'] : null, $now ); if ($effectiveStatus === self::STATUS_OFFLINE) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_OFFLINE, 'severity' => 'danger', 'message' => 'Gateway heartbeat has expired and the gateway is offline.', 'recommended_action' => 'restart_agent', ]; } elseif ($effectiveStatus === self::STATUS_DEGRADED || ($heartbeatAge !== null && $heartbeatAge >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS)) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_STALE_HEARTBEAT, 'severity' => 'warning', 'message' => 'Gateway heartbeat is stale and control traffic may degrade.', 'recommended_action' => 'inspect_connectivity', ]; } $activeOperation = isset($gateway['active_operation']) && is_array($gateway['active_operation']) ? (array)$gateway['active_operation'] : null; if ($activeOperation !== null && !empty($activeOperation['started_at'])) { $startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']); if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT, 'severity' => 'warning', 'message' => 'The active gateway operation has exceeded the expected timeout.', 'recommended_action' => 'retry_operation', ]; } } if (!empty($gateway['version_drift']['is_drifted'])) { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_VERSION_DRIFT', 'severity' => 'info', 'message' => 'Installed gateway version differs from the target version.', 'recommended_action' => 'queue_update', ]; } if (($gateway['credential_freshness']['state'] ?? 'UNKNOWN') === 'STALE') { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_CREDENTIALS_STALE', 'severity' => 'warning', 'message' => 'Gateway credentials have not been rotated recently.', 'recommended_action' => 'rotate_credentials', ]; } $containerHealth = isset($gateway['container_health']) && is_array($gateway['container_health']) ? (array)$gateway['container_health'] : []; $containerState = strtoupper((string)($containerHealth['state'] ?? self::STATUS_ONLINE)); if (in_array($containerState, [self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_CONTAINER_DEGRADED', 'severity' => $containerState === self::STATUS_OFFLINE ? 'danger' : 'warning', 'message' => 'One or more compose services are not healthy on the gateway.', 'recommended_action' => 'restart_agent', ]; } $outboxStatus = isset($gateway['outbox_status']) && is_array($gateway['outbox_status']) ? (array)$gateway['outbox_status'] : []; if ((int)($outboxStatus['queued'] ?? 0) > 0) { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_OUTBOX_BACKLOG', 'severity' => 'warning', 'message' => 'The gateway has queued outbound control-plane items waiting for replay.', 'recommended_action' => 'inspect_connectivity', ]; } $rollbackStatus = isset($gateway['rollback_status']) && is_array($gateway['rollback_status']) ? (array)$gateway['rollback_status'] : []; $rollbackState = strtoupper((string)($rollbackStatus['state'] ?? 'IDLE')); if ($rollbackState === 'ROLLED_BACK') { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_UPDATE_ROLLED_BACK', 'severity' => 'warning', 'message' => 'The last container rollout was rolled back automatically.', 'recommended_action' => 'review_diagnostics', ]; } elseif ($rollbackState === 'FAILED') { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_ROLLBACK_FAILED', 'severity' => 'danger', 'message' => 'Gateway rollback failed and manual intervention is required.', 'recommended_action' => 'review_diagnostics', ]; } return $diagnostics; } private static function primaryGatewayErrorState(array $diagnostics, array $gateway): ?array { if ($diagnostics !== []) { return [ 'code' => (string)$diagnostics[0]['code'], 'message' => (string)$diagnostics[0]['message'], 'recommended_action' => $diagnostics[0]['recommended_action'] ?? null, ]; } $activeStatus = strtoupper((string)($gateway['active_operation']['status'] ?? '')); if (in_array($activeStatus, [ edge_gateway_operation_service::STATUS_CANCEL_REQUESTED, edge_gateway_operation_service::STATUS_CANCELLED, ], true)) { return null; } if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) { return [ 'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED', 'message' => $gateway['active_operation']['error_message'] ?? 'Gateway operation failed', 'recommended_action' => 'retry_operation', ]; } return null; } private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string { foreach ($jobs as $job) { if (!is_array($job)) { continue; } if (($job['status'] ?? null) !== 'COMPLETED') { continue; } if ($commandType !== null && ($job['command_type'] ?? null) !== $commandType) { continue; } return isset($job['completed_at']) ? (string)$job['completed_at'] : null; } return null; } private static function findLatestShellTimestamp(array $sessions): ?string { foreach ($sessions as $session) { if (!is_array($session)) { continue; } foreach (['opened_at', 'approved_at', 'created_at'] as $field) { if (!empty($session[$field])) { return (string)$session[$field]; } } } return null; } /** * @param array $timestamps */ private static function latestTimestamp(array $timestamps): ?string { $latestValue = null; $latestEpoch = 0; foreach ($timestamps as $timestamp) { if (!is_string($timestamp) || trim($timestamp) === '') { continue; } $epoch = self::parseApplicationDateTime($timestamp); if ($epoch === null) { continue; } if ($latestValue === null || $epoch >= $latestEpoch) { $latestValue = $timestamp; $latestEpoch = $epoch; } } return $latestValue; } private static function normalizeFallbackMode(?string $fallbackMode): string { $normalized = strtoupper(trim((string)$fallbackMode)); if (in_array($normalized, [ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_LOCAL_ONLY, self::RELAY_FALLBACK_CLOUD_ONLY, ], true)) { return $normalized; } return self::RELAY_FALLBACK_PREFER_LOCAL; } private function clearObjectPropertyCache(string $table, int $id): void { if ($id <= 0 || !defined('redis')) { return; } $normalizedTable = trim($table, " `\t\n\r\0\x0B"); redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*'); } private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string { if ($device === null) { return 'MISSING'; } if (isset($device['online']) && $device['online'] === false) { return 'OFFLINE'; } if ($ageSeconds === null) { return 'UNKNOWN'; } if ($ageSeconds >= self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS) { return 'STALE'; } return 'READY'; } public static function resolveGatewayStatus(?string $reportedStatus, ?string $lastHeartbeatAt, ?int $now = null): string { $normalizedStatus = self::normalizeGatewayStatus($reportedStatus); $heartbeatAgeSeconds = self::heartbeatAgeSeconds($lastHeartbeatAt, $now); if ($normalizedStatus === self::STATUS_OFFLINE) { return self::STATUS_OFFLINE; } if ($heartbeatAgeSeconds === null || $heartbeatAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) { return self::STATUS_OFFLINE; } if ($normalizedStatus === self::STATUS_DEGRADED) { return self::STATUS_DEGRADED; } if ($normalizedStatus === self::STATUS_ONLINE && $heartbeatAgeSeconds >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS) { return self::STATUS_DEGRADED; } return $normalizedStatus; } public static function resolveDiscoveryStatus(?string $discoveryStatus, string $effectiveStatus): string { $normalizedStatus = trim(strtoupper((string)$discoveryStatus)); if ($effectiveStatus === self::STATUS_OFFLINE && $normalizedStatus === 'READY') { return 'STALE'; } return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; } private static function normalizeGatewayStatus(?string $reportedStatus): string { $normalizedStatus = trim(strtoupper((string)$reportedStatus)); if (in_array($normalizedStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) { return $normalizedStatus; } return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; } private static function heartbeatAgeSeconds(?string $lastHeartbeatAt, ?int $now = null): ?int { if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { return null; } $heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt); if ($heartbeatTimestamp === null) { return null; } return max(0, ($now ?? time()) - $heartbeatTimestamp); } private static function heartbeatTimestamp(?string $lastHeartbeatAt): int { if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { return 0; } return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0; } private static function statusPriority(string $status): int { return match ($status) { self::STATUS_ONLINE => 3, self::STATUS_DEGRADED => 2, self::STATUS_OFFLINE => 1, default => 0, }; } private function writeAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void { (new edge_gateway_audit_logs_o())->add_object([ 'gateway_id' => $gatewayId, 'department_id' => $departmentId, 'action' => $action, 'actor_user_id' => $userId, 'actor_type' => $userId === null ? 'SYSTEM' : 'USER', 'severity' => 'INFO', 'context_json' => $context, ]); } private function buildSignedBrokerToken(array $payload): string { $body = self::base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)); $signature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true)); return $body . '.' . $signature; } /** * @return array * @throws Exception */ private function parseSignedBrokerToken(string $token): array { $token = trim($token); if ($token === '' || !str_contains($token, '.')) { throw new Exception('Missing broker session token'); } [$body, $signature] = explode('.', $token, 2); $expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true)); if (!hash_equals($expectedSignature, $signature)) { throw new Exception('Invalid broker session token'); } $decoded = json_decode((string)self::base64UrlDecode($body), true); if (!is_array($decoded)) { throw new Exception('Broker session token payload is invalid'); } $expiresAt = isset($decoded['exp']) ? (int)$decoded['exp'] : 0; if ($expiresAt > 0 && $expiresAt <= time()) { throw new Exception('Broker session token expired'); } return $decoded; } private function findShellSessionByToken(string $plainToken): edge_gateway_shell_sessions_o { $tokenHash = $this->hashToken($plainToken); $rows = (new edge_gateway_shell_sessions_o())->getFieldsWhere([ 'session_token_hash' => $tokenHash, 'deleted_at' => null, ], ['id']); $sessionId = isset($rows[0]['id']) ? (int)$rows[0]['id'] : 0; $session = (new edge_gateway_shell_sessions_o())->select($sessionId); if (!$session->exists()) { throw new Exception('Shell session not found'); } return $session; } private function brokerSessionSecret(): string { $secret = trim((string)(getenv('EDGE_GATEWAY_SESSION_SECRET') ?: '')); if ($secret !== '') { return $secret; } $fallback = $this->configuredBrokerSharedSecret(); if ($fallback !== '') { return $fallback; } return hash('sha256', $this->getApiBaseUrl() . '::edgegateway'); } private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private static function base64UrlDecode(string $value): string { $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } return (string)base64_decode(strtr($value, '-_', '+/')); } private function hashToken(string $plainToken): string { return hash('sha256', $plainToken); } public static function parseApplicationDateTime(?string $value): ?int { $normalized = trim((string)$value); if ($normalized === '') { return null; } $timezone = self::applicationTimeZone(); $dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone); if ($dateTime instanceof \DateTimeImmutable) { return $dateTime->getTimestamp(); } try { return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp(); } catch (\Throwable) { return null; } } public static function formatApplicationDateTime(int $timestamp): string { return (new \DateTimeImmutable('@' . $timestamp)) ->setTimezone(self::applicationTimeZone()) ->format('Y-m-d H:i:s'); } private static function applicationTimeZone(): \DateTimeZone { $timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen')); if ($timezone === '') { $timezone = 'Europe/Copenhagen'; } try { return new \DateTimeZone($timezone); } catch (\Throwable) { return new \DateTimeZone('Europe/Copenhagen'); } } private function now(): string { return self::formatApplicationDateTime(time()); } private function formatDateTime(int $timestamp): string { return self::formatApplicationDateTime($timestamp); } private function remoteIp(): ?string { $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); return $ip !== '' ? $ip : null; } }