4841 lines
190 KiB
PHP
4841 lines
190 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use objects\departments_o;
|
|
use objects\department_variables_o;
|
|
use objects\edge_gateway_audit_logs_o;
|
|
use objects\edge_gateway_claim_tokens_o;
|
|
use objects\edge_gateway_command_jobs_o;
|
|
use objects\edge_gateway_device_inventory_o;
|
|
use objects\edge_gateway_log_entries_o;
|
|
use objects\edge_gateway_relay_bindings_o;
|
|
use objects\edge_gateway_shell_sessions_o;
|
|
use objects\edge_gateways_o;
|
|
|
|
class edge_gateway_manager
|
|
{
|
|
public const DEPARTMENT_VARIABLE_TRANSPORT_MODE = 'shelly_transport_mode';
|
|
public const TRANSPORT_MODE_CLOUD = 'cloud';
|
|
public const TRANSPORT_MODE_GATEWAY = 'gateway';
|
|
public const DELIVERY_CHANNEL_BROKER = 'BROKER_FAST_PATH';
|
|
public const DELIVERY_CHANNEL_API = 'API_POLLING';
|
|
public const DELIVERY_CHANNEL_CLOUD = 'CLOUD';
|
|
public const RELAY_FALLBACK_PREFER_LOCAL = 'PREFER_LOCAL';
|
|
public const RELAY_FALLBACK_LOCAL_ONLY = 'LOCAL_ONLY';
|
|
public const RELAY_FALLBACK_CLOUD_ONLY = 'CLOUD_ONLY';
|
|
public const STATUS_PENDING = 'PENDING';
|
|
public const STATUS_ONLINE = 'ONLINE';
|
|
public const STATUS_DEGRADED = 'DEGRADED';
|
|
public const STATUS_OFFLINE = 'OFFLINE';
|
|
public const DEFAULT_RELEASE_CHANNEL = 'stable';
|
|
public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service';
|
|
public const DEFAULT_STACK_SERVICE_NAME = 'truckwash-edge-gateway-stack.service';
|
|
public const DEFAULT_COMPOSE_STACK_FILE = 'docker-compose.gateway.yml';
|
|
public const DEFAULT_LAUNCHER_SCRIPT_NAME = 'gateway-launcher.sh';
|
|
public const DEFAULT_LAN_WORKER_ARTIFACT = 'lan-worker.php';
|
|
public const DEFAULT_AUTO_UPDATER_ARTIFACT = 'auto-updater.php';
|
|
public const DEFAULT_EDGE_AGENT_DOCKERFILE = 'Dockerfile.edge-agent';
|
|
public const DEFAULT_LAN_WORKER_DOCKERFILE = 'Dockerfile.lan-worker';
|
|
public const DEFAULT_AUTO_UPDATER_DOCKERFILE = 'Dockerfile.auto-updater';
|
|
public const DEFAULT_INSTALL_DIR = '/opt/truckwash-edge-agent';
|
|
public const DEFAULT_RUNTIME_DIR = '/opt/truckwash-edge-agent/runtime';
|
|
public const DEFAULT_STATE_DATABASE_PATH = '/opt/truckwash-edge-agent/runtime/gateway-state.sqlite';
|
|
public const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
|
public const DEFAULT_COMPOSE_PROJECT_NAME = 'truckwash-edge-gateway';
|
|
public const DEFAULT_EDGE_AGENT_BASE_IMAGE = 'php:8.2-cli-bookworm';
|
|
public const DEFAULT_LAN_WORKER_BASE_IMAGE = 'php:8.2-cli-bookworm';
|
|
public const DEFAULT_AUTO_UPDATER_BASE_IMAGE = 'php:8.2-cli-bookworm';
|
|
public const DEFAULT_REDIS_BASE_IMAGE = 'redis:7-alpine';
|
|
public const DEFAULT_MARIADB_BASE_IMAGE = 'mariadb:11';
|
|
public const DEFAULT_MINIO_BASE_IMAGE = 'minio/minio:latest';
|
|
public const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
|
public const INSTALL_TOKEN_TTL_SECONDS = 1800;
|
|
public const INSTALL_SESSION_STATUS_PENDING = 'PENDING';
|
|
public const INSTALL_SESSION_STATUS_RUNNING = 'RUNNING';
|
|
public const INSTALL_SESSION_STATUS_CLAIMED = 'CLAIMED';
|
|
public const INSTALL_SESSION_STATUS_FAILED = 'FAILED';
|
|
public const INSTALL_SESSION_STATUS_EXPIRED = 'EXPIRED';
|
|
private const INSTALL_SESSION_EVENT_LIMIT = 12;
|
|
private const INSTALL_SESSION_DIAGNOSTIC_LIMIT = 6;
|
|
private const INSTALL_SESSION_OUTPUT_LIMIT = 2000;
|
|
public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
|
|
public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300;
|
|
public const COMMAND_WAIT_TIMEOUT_SECONDS = 10;
|
|
public const COMMAND_POLL_TIMEOUT_SECONDS = 20;
|
|
public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000;
|
|
public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30;
|
|
public const COMMAND_EXPIRES_AFTER_SECONDS = 90;
|
|
public const BROKER_PRESENCE_TTL_SECONDS = 90;
|
|
public const BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS = 45;
|
|
public const BROKER_HTTP_TIMEOUT_SECONDS = 5;
|
|
public const BROWSER_STREAM_TOKEN_TTL_SECONDS = 300;
|
|
public const SHELL_SESSION_TTL_SECONDS = 900;
|
|
public const DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS = 180;
|
|
public const CREDENTIAL_FRESH_AFTER_SECONDS = 2592000;
|
|
|
|
public function __construct()
|
|
{
|
|
edge_gateway_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
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.',
|
|
], strtotime($expiresAt) - 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),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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() ?? []);
|
|
$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(array_merge($existingMetadata, (array)($payload['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;
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
* @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'];
|
|
$gateway = $this->requireGateway($gatewayId);
|
|
$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<int,array<string,mixed>> $gateways
|
|
* @return array<string,mixed>
|
|
* @throws Exception
|
|
*/
|
|
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
|
|
{
|
|
$fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false);
|
|
|
|
return self::summarizeFleetUsageFromGatewayRows($fleet);
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $gateways
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<int,array<string,mixed>> $gateways
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function summarizeFleetUsageFromGatewayRows(array $gateways): array
|
|
{
|
|
return self::summarizeFleetUsage(
|
|
$gateways,
|
|
self::aggregateInventoryUsageFromGatewayRows($gateways),
|
|
self::aggregateBindingUsageFromGatewayRows($gateways)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $gateway
|
|
* @return array<string,mixed>
|
|
*/
|
|
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;
|
|
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<int,array<string,mixed>>
|
|
* @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']);
|
|
$bindingObject->device_id->set($deviceId);
|
|
$bindingObject->local_ip->set($binding['local_ip'] ?? null);
|
|
$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;
|
|
}
|
|
|
|
(new edge_gateway_relay_bindings_o())->add_object([
|
|
'gateway_id' => $gatewayId,
|
|
'department_id' => $departmentId,
|
|
'relay_id' => $relayId,
|
|
'device_id' => $deviceId,
|
|
'local_ip' => $binding['local_ip'] ?? null,
|
|
'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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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'], 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
|
|
{
|
|
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
|
|
$gateway = $this->requireGateway((int)$binding['gateway_id']);
|
|
$resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
|
|
|
|
if (($resolution['execution_path'] ?? 'local') === 'cloud') {
|
|
return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution);
|
|
}
|
|
|
|
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [
|
|
'relayId' => $logicalRelayId,
|
|
'deviceId' => $binding['device_id'],
|
|
'localIp' => $binding['local_ip'],
|
|
'channel' => (int)$binding['channel'],
|
|
], null, [
|
|
'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
|
|
'fallback_reason' => $resolution['reason'] ?? null,
|
|
]);
|
|
|
|
try {
|
|
$result = $this->dispatchGatewayCommand($gateway, $job);
|
|
return $this->finalizeRelayDispatch($binding, $resolution, $result);
|
|
} catch (Exception $exception) {
|
|
return $this->handleRelayDispatchFailure(
|
|
$departmentId,
|
|
$logicalRelayId,
|
|
$binding,
|
|
$resolution,
|
|
null,
|
|
$exception
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
|
|
{
|
|
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
|
|
$gateway = $this->requireGateway((int)$binding['gateway_id']);
|
|
$resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
|
|
|
|
if (($resolution['execution_path'] ?? 'local') === 'cloud') {
|
|
return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution);
|
|
}
|
|
|
|
$job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [
|
|
'relayId' => $logicalRelayId,
|
|
'deviceId' => $binding['device_id'],
|
|
'localIp' => $binding['local_ip'],
|
|
'channel' => (int)$binding['channel'],
|
|
'on' => $on,
|
|
], null, [
|
|
'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
|
|
'fallback_reason' => $resolution['reason'] ?? null,
|
|
]);
|
|
|
|
try {
|
|
$result = $this->dispatchGatewayCommand($gateway, $job);
|
|
return $this->finalizeRelayDispatch($binding, $resolution, $result);
|
|
} catch (Exception $exception) {
|
|
return $this->handleRelayDispatchFailure(
|
|
$departmentId,
|
|
$logicalRelayId,
|
|
$binding,
|
|
$resolution,
|
|
$on,
|
|
$exception
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public function listBindings(int $gatewayId): array
|
|
{
|
|
return $this->listRecentObjects(new edge_gateway_relay_bindings_o(), [
|
|
'gateway_id' => $gatewayId,
|
|
'deleted_at' => null,
|
|
], 100);
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
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 "...<trimmed>"; 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
|
|
);
|
|
|
|
$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) {
|
|
$timeline[] = [
|
|
'type' => 'log',
|
|
'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,
|
|
'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);
|
|
$sessionToken = bin2hex(random_bytes(32));
|
|
$expiresAt = $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS);
|
|
|
|
$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,
|
|
'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' => $this->buildBrokerPublicUrl(),
|
|
'ws_url' => $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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 = $session->expires_at->value() === null ? null : strtotime((string)$session->expires_at->value());
|
|
if ($expiresAt !== null && $expiresAt !== false && $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
|
|
{
|
|
$session = $this->findShellSessionByToken($plainToken);
|
|
$metadata = (array)($session->metadata_json->value() ?? []);
|
|
$metadata['close_reason'] = $reason;
|
|
$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();
|
|
}
|
|
|
|
/**
|
|
* @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 recordTelemetryFromBroker(int $gatewayId, array $payload): array
|
|
{
|
|
$gateway = $this->requireGateway($gatewayId);
|
|
$metadata = array_merge(
|
|
(array)($gateway->metadata_json->value() ?? []),
|
|
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
|
);
|
|
|
|
$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->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<string,mixed> $session
|
|
* @param array<string,mixed> $update
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $session
|
|
* @return array<string,mixed>
|
|
*/
|
|
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 && strtotime($expiresAt) < ($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<int,array<string,string>>
|
|
*/
|
|
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<int,array<string,string>>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
return $scheme . '://' . $host;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
return $gateway;
|
|
}
|
|
|
|
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 (strtotime((string)$claimToken->expires_at->value()) < 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);
|
|
}
|
|
|
|
/**
|
|
* @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 (microtime(true) >= $deadline) {
|
|
break;
|
|
}
|
|
|
|
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
|
|
} while (true);
|
|
|
|
$job = (new edge_gateway_command_jobs_o())->select($jobId);
|
|
if ($job->exists()) {
|
|
$job->delivery_json->set($this->buildDeliveryMetadata(
|
|
array_merge(
|
|
(array)($job->delivery_json->value() ?? []),
|
|
['last_dispatch_error' => 'Edge gateway command timed out']
|
|
),
|
|
self::COMMAND_EXPIRES_AFTER_SECONDS
|
|
));
|
|
}
|
|
|
|
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
|
|
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;
|
|
}
|
|
|
|
$update = $pdo->prepare(
|
|
'UPDATE edge_gateway_command_jobs
|
|
SET status = :status,
|
|
response_json = :response_json,
|
|
delivery_json = JSON_SET(
|
|
COALESCE(delivery_json, JSON_OBJECT()),
|
|
\'$.delivery_channel\', :delivery_channel,
|
|
\'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1,
|
|
\'$.last_dispatch_error\', CAST(NULL AS JSON)
|
|
),
|
|
error_message = NULL,
|
|
completed_at = NULL
|
|
WHERE id = :id'
|
|
);
|
|
$update->execute([
|
|
':status' => 'DISPATCHING',
|
|
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
|
':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE),
|
|
':id' => (int)$row['id'],
|
|
]);
|
|
|
|
$pdo->commit();
|
|
} catch (\Throwable $throwable) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $throwable;
|
|
}
|
|
|
|
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
|
|
): 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' : 'FAILED');
|
|
|
|
$this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $inventory
|
|
*/
|
|
private function syncDeviceInventory(int $gatewayId, array $inventory): void
|
|
{
|
|
foreach ($inventory as $device) {
|
|
$deviceId = trim((string)($device['device_id'] ?? $device['id'] ?? ''));
|
|
if ($deviceId === '') {
|
|
continue;
|
|
}
|
|
|
|
$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' => $device['local_ip'] ?? $device['ip'] ?? null,
|
|
'model' => $device['model'] ?? null,
|
|
'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1),
|
|
'capabilities_json' => (array)($device['capabilities'] ?? []),
|
|
'online' => (bool)($device['online'] ?? true),
|
|
'last_seen_at' => $this->now(),
|
|
'metadata_json' => (array)($device['metadata'] ?? []),
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
$inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
|
|
$inventoryObject->local_ip->set($device['local_ip'] ?? $device['ip'] ?? null);
|
|
$inventoryObject->model->set($device['model'] ?? null);
|
|
$inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1));
|
|
$inventoryObject->capabilities_json->set((array)($device['capabilities'] ?? []));
|
|
$inventoryObject->online->set((bool)($device['online'] ?? true));
|
|
$inventoryObject->last_seen_at->set($this->now());
|
|
$inventoryObject->metadata_json->set((array)($device['metadata'] ?? []));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
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 = array_filter([
|
|
'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,
|
|
], static fn(mixed $value): bool => $value !== null);
|
|
|
|
$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 = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''));
|
|
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 = trim((string)(getenv('EDGE_BROKER_URL') ?: ''));
|
|
return $configured !== '' ? rtrim($configured, '/') : null;
|
|
}
|
|
|
|
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 !empty($presence['connected']) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API;
|
|
}
|
|
|
|
public function validateBrokerSharedSecret(?string $secret): bool
|
|
{
|
|
$configured = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
|
if ($configured === '') {
|
|
return true;
|
|
}
|
|
|
|
return $secret !== null && hash_equals($configured, trim($secret));
|
|
}
|
|
|
|
/**
|
|
* @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: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''))],
|
|
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;
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array
|
|
{
|
|
$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');
|
|
}
|
|
|
|
$delivery = (array)($job->delivery_json->value() ?? []);
|
|
$preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API);
|
|
|
|
if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER && $this->buildBrokerInternalUrl() !== null) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
return $this->waitForCommandResult((int)$job->id);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
$presence = $this->readBrokerPresence((int)$gateway->id);
|
|
if (empty($presence['connected'])) {
|
|
throw new Exception('Edge broker fast path is unavailable');
|
|
}
|
|
|
|
$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: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')),
|
|
],
|
|
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
|
|
): array {
|
|
$transport = new cloud_shelly_transport();
|
|
$response = $on === null
|
|
? $transport->sendPostRequest('/v2/devices/api/get', ['ids' => [$logicalRelayId]], $departmentId)
|
|
: $transport->sendPostRequest('/v2/devices/api/set/switch', ['id' => $logicalRelayId, 'on' => $on], $departmentId);
|
|
|
|
$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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function handleRelayDispatchFailure(
|
|
int $departmentId,
|
|
string $logicalRelayId,
|
|
array $binding,
|
|
array $resolution,
|
|
?bool $on,
|
|
Exception $exception
|
|
): 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'],
|
|
])
|
|
);
|
|
} 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
|
|
{
|
|
$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);
|
|
|
|
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<int,int> $gatewayIds
|
|
* @return array<string,int>
|
|
*/
|
|
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<int,int> $gatewayIds
|
|
* @return array<string,int>
|
|
*/
|
|
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<int,int> $gatewayIds
|
|
* @return array<int,array<string,int>>
|
|
*/
|
|
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<int,int> $gatewayIds
|
|
* @return array<int,array<string,int>>
|
|
*/
|
|
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<int,array<string,mixed>> $gateways
|
|
* @param array<int,array<string,int>> $inventoryUsageByGateway
|
|
* @param array<int,array<string,int>> $bindingUsageByGateway
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
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<int,array<string,mixed>> $gateways
|
|
* @return array<string,int>
|
|
*/
|
|
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<int,array<string,mixed>> $gateways
|
|
* @return array<string,int>
|
|
*/
|
|
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<string,mixed> $gateway
|
|
* @param array<string,int>|null $inventoryUsage
|
|
* @param array<string,int>|null $bindingUsage
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $gateway
|
|
* @param array<string,int>|null $summary
|
|
* @return array<string,int>
|
|
*/
|
|
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<string,mixed> $gateway
|
|
* @param array<string,int>|null $summary
|
|
* @return array<string,int>
|
|
*/
|
|
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<string,int>
|
|
*/
|
|
private static function emptyInventoryUsage(): array
|
|
{
|
|
return [
|
|
'total' => 0,
|
|
'online' => 0,
|
|
'offline' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string,int>
|
|
*/
|
|
private static function emptyBindingUsage(): array
|
|
{
|
|
return [
|
|
'total' => 0,
|
|
'fallback_overrides' => 0,
|
|
'cloud_only' => 0,
|
|
'local_only' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int,float|int> $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<int,float|int> $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']
|
|
: [];
|
|
$brokerConnected = !empty($brokerPresence['connected']);
|
|
$brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null;
|
|
$brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $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 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<int,array<string,string>>
|
|
*/
|
|
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 = strtotime((string)$activeOperation['started_at']);
|
|
if ($startedAt !== false && (($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<int,mixed> $timestamps
|
|
*/
|
|
private static function latestTimestamp(array $timestamps): ?string
|
|
{
|
|
$latestValue = null;
|
|
$latestEpoch = 0;
|
|
|
|
foreach ($timestamps as $timestamp) {
|
|
if (!is_string($timestamp) || trim($timestamp) === '') {
|
|
continue;
|
|
}
|
|
|
|
$epoch = strtotime($timestamp);
|
|
if ($epoch === false) {
|
|
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 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 = strtotime($lastHeartbeatAt);
|
|
if ($heartbeatTimestamp === false) {
|
|
return null;
|
|
}
|
|
|
|
return max(0, ($now ?? time()) - $heartbeatTimestamp);
|
|
}
|
|
|
|
private static function heartbeatTimestamp(?string $lastHeartbeatAt): int
|
|
{
|
|
if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') {
|
|
return 0;
|
|
}
|
|
|
|
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
|
|
return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp;
|
|
}
|
|
|
|
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<string,mixed>
|
|
* @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 = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
|
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);
|
|
}
|
|
|
|
private function now(): string
|
|
{
|
|
return date('Y-m-d H:i:s');
|
|
}
|
|
|
|
private function formatDateTime(int $timestamp): string
|
|
{
|
|
return date('Y-m-d H:i:s', $timestamp);
|
|
}
|
|
|
|
private function remoteIp(): ?string
|
|
{
|
|
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
|
return $ip !== '' ? $ip : null;
|
|
}
|
|
}
|