1456 lines
51 KiB
PHP
1456 lines
51 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_relay_bindings_o;
|
|
use objects\edge_gateway_shell_sessions_o;
|
|
use objects\edge_gateway_update_jobs_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 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 INSTALL_TOKEN_TTL_SECONDS = 1800;
|
|
public const SHELL_SESSION_TTL_SECONDS = 900;
|
|
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 function __construct(private readonly ?edge_broker_client $brokerClient = null)
|
|
{
|
|
edge_gateway_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array
|
|
{
|
|
$this->requireDepartment($departmentId);
|
|
|
|
$token = bin2hex(random_bytes(24));
|
|
$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' => $this->formatDateTime(time() + self::INSTALL_TOKEN_TTL_SECONDS),
|
|
'metadata_json' => [],
|
|
]);
|
|
|
|
$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::DEFAULT_RELEASE_CHANNEL,
|
|
'installed_version' => $installedVersion,
|
|
'target_version' => $installedVersion,
|
|
'last_heartbeat_at' => $this->now(),
|
|
'last_seen_ip' => $this->remoteIp(),
|
|
'discovery_status' => 'PENDING',
|
|
'is_primary' => 1,
|
|
'metadata_json' => $metadata,
|
|
]);
|
|
|
|
$claimToken->used_at->set($this->now());
|
|
$gateway->select($gatewayId);
|
|
|
|
$this->writeAudit(
|
|
$gatewayId,
|
|
$departmentId,
|
|
'GATEWAY_CLAIMED',
|
|
null,
|
|
['hostname' => $hostname, 'installed_version' => $installedVersion]
|
|
);
|
|
|
|
return [
|
|
'gateway' => $this->getGateway($gatewayId),
|
|
'agent_token' => $agentToken,
|
|
'broker_url' => $this->getBrokerPublicUrl(),
|
|
'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat',
|
|
'release_channel' => (string)$gateway->release_channel->value(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array
|
|
{
|
|
$gateway = $this->requireGateway($gatewayId);
|
|
$token = bin2hex(random_bytes(32));
|
|
$gateway->agent_token_hash->set($this->hashToken($token));
|
|
|
|
$this->writeAudit(
|
|
$gatewayId,
|
|
(int)$gateway->department_id->value(),
|
|
'GATEWAY_CREDENTIALS_ROTATED',
|
|
$userId,
|
|
[]
|
|
);
|
|
|
|
return [
|
|
'gateway' => $this->getGateway($gatewayId),
|
|
'agent_token' => $token,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
$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)($payload['metadata'] ?? $gateway->metadata_json->value() ?? []));
|
|
|
|
if (isset($payload['inventory']) && is_array($payload['inventory'])) {
|
|
$this->syncDeviceInventory($gatewayId, $payload['inventory']);
|
|
}
|
|
|
|
return $this->getGateway($gatewayId);
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
* @throws Exception
|
|
*/
|
|
public function listGateways(?int $departmentId = null): 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 = [];
|
|
foreach ($rows as $row) {
|
|
$gateways[] = $this->getGateway((int)$row['id']);
|
|
}
|
|
|
|
usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id']));
|
|
return $gateways;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function getGateway(int $gatewayId): array
|
|
{
|
|
$gateway = $this->requireGateway($gatewayId);
|
|
$data = $gateway->asArray();
|
|
$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['recent_updates'] = $this->listRecentObjects(new edge_gateway_update_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]);
|
|
$data['recent_shell_sessions'] = $this->listRecentObjects(new edge_gateway_shell_sessions_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]);
|
|
$data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]);
|
|
$data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value());
|
|
return self::deriveGatewayRuntimeState($data);
|
|
}
|
|
|
|
/**
|
|
* @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]
|
|
);
|
|
|
|
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');
|
|
}
|
|
|
|
$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((array)($binding['metadata'] ?? []));
|
|
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' => (array)($binding['metadata'] ?? []),
|
|
]);
|
|
}
|
|
|
|
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)]
|
|
);
|
|
|
|
return $this->listBindings($gatewayId);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function queueDiscovery(int $gatewayId, ?int $userId = null): array
|
|
{
|
|
$gateway = $this->requireDispatchableGateway($gatewayId);
|
|
$gateway->discovery_status->set('PENDING');
|
|
$this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId);
|
|
|
|
return $this->getGateway($gatewayId);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array
|
|
{
|
|
$gateway = $this->requireDispatchableGateway($gatewayId);
|
|
$gateway->target_version->set($targetVersion);
|
|
|
|
$jobObject = new edge_gateway_update_jobs_o();
|
|
$jobId = $jobObject->add_object([
|
|
'gateway_id' => $gatewayId,
|
|
'command_job_id' => null,
|
|
'target_version' => $targetVersion,
|
|
'release_channel' => $releaseChannel,
|
|
'status' => 'PENDING',
|
|
'requested_by' => $userId,
|
|
'requested_at' => $this->now(),
|
|
'result_json' => [],
|
|
]);
|
|
$jobObject->select($jobId);
|
|
|
|
$command = $this->createCommandJob($gatewayId, 'RUN_UPDATE', [
|
|
'targetVersion' => $targetVersion,
|
|
'releaseChannel' => $releaseChannel,
|
|
], $userId);
|
|
$jobObject->command_job_id->set((int)$command->id);
|
|
|
|
$this->writeAudit(
|
|
$gatewayId,
|
|
(int)$gateway->department_id->value(),
|
|
'UPDATE_QUEUED',
|
|
$userId,
|
|
['target_version' => $targetVersion, 'release_channel' => $releaseChannel]
|
|
);
|
|
|
|
return $jobObject->asArray();
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
|
|
return [
|
|
'acknowledged' => true,
|
|
'job' => $job->asArray(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function createShellSession(int $gatewayId, string $reason, ?int $userId = null): array
|
|
{
|
|
$gateway = $this->requireGateway($gatewayId);
|
|
$sessionToken = bin2hex(random_bytes(32));
|
|
|
|
$sessionObject = new edge_gateway_shell_sessions_o();
|
|
$sessionId = $sessionObject->add_object([
|
|
'gateway_id' => $gatewayId,
|
|
'reason' => $reason,
|
|
'approval_status' => 'APPROVED',
|
|
'session_token_hash' => $this->hashToken($sessionToken),
|
|
'requested_by' => $userId,
|
|
'approved_by' => $userId,
|
|
'approved_at' => $this->now(),
|
|
'expires_at' => $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS),
|
|
'metadata_json' => [
|
|
'ttl_seconds' => self::SHELL_SESSION_TTL_SECONDS,
|
|
],
|
|
]);
|
|
$sessionObject->select($sessionId);
|
|
|
|
$this->writeAudit(
|
|
$gatewayId,
|
|
(int)$gateway->department_id->value(),
|
|
'ROOT_SHELL_APPROVED',
|
|
$userId,
|
|
['reason' => $reason, 'session_id' => $sessionId]
|
|
);
|
|
|
|
return [
|
|
'session' => $sessionObject->asArray(),
|
|
'session_token' => $sessionToken,
|
|
'websocket_url' => $this->buildBrowserShellWsUrl($sessionToken),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function validateShellSessionToken(string $plainToken): array
|
|
{
|
|
$tokenHash = $this->hashToken($plainToken);
|
|
$rows = (new edge_gateway_shell_sessions_o())->getFieldsWhere([
|
|
'session_token_hash' => $tokenHash,
|
|
'deleted_at' => null,
|
|
], ['id']);
|
|
|
|
if ($rows === []) {
|
|
throw new Exception('Invalid shell session token');
|
|
}
|
|
|
|
$session = (new edge_gateway_shell_sessions_o())->select((int)$rows[0]['id']);
|
|
if (!$session->exists()) {
|
|
throw new Exception('Shell session not found');
|
|
}
|
|
if (strtotime((string)$session->expires_at->value()) < time()) {
|
|
throw new Exception('Shell session has expired');
|
|
}
|
|
|
|
return $session->asArray();
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function closeShellSession(string $plainToken, string $transcript, string $closedReason): array
|
|
{
|
|
$sessionData = $this->validateShellSessionToken($plainToken);
|
|
$session = (new edge_gateway_shell_sessions_o())->select((int)$sessionData['id']);
|
|
$session->closed_at->set($this->now());
|
|
$session->transcript_text->set($transcript);
|
|
$metadata = (array)($session->metadata_json->value() ?? []);
|
|
$metadata['closed_reason'] = $closedReason;
|
|
$session->metadata_json->set($metadata);
|
|
|
|
$this->writeAudit(
|
|
(int)$session->gateway_id->value(),
|
|
null,
|
|
'ROOT_SHELL_CLOSED',
|
|
null,
|
|
['session_id' => (int)$session->id, 'closed_reason' => $closedReason]
|
|
);
|
|
|
|
return $session->asArray();
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array
|
|
{
|
|
$gateway = $this->getPrimaryGatewayForDepartment($departmentId);
|
|
$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->requireDispatchableGateway((int)$binding['gateway_id']);
|
|
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [
|
|
'relayId' => $logicalRelayId,
|
|
'deviceId' => $binding['device_id'],
|
|
'localIp' => $binding['local_ip'],
|
|
'channel' => (int)$binding['channel'],
|
|
], null);
|
|
|
|
$this->tryImmediateBrokerDispatch($job, $gateway);
|
|
|
|
return $this->waitForCommandResult((int)$job->id);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
|
|
{
|
|
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
|
|
$gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']);
|
|
$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);
|
|
|
|
$this->tryImmediateBrokerDispatch($job, $gateway);
|
|
|
|
return $this->waitForCommandResult((int)$job->id);
|
|
}
|
|
|
|
/**
|
|
* @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 ' . escapeshellarg($this->buildInstallScriptUrl($plainToken)) . ' | sudo bash';
|
|
}
|
|
|
|
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->getBrokerPublicUrl(),
|
|
'installToken' => $plainToken,
|
|
'gatewayId' => null,
|
|
'agentToken' => null,
|
|
'heartbeatIntervalSeconds' => 15,
|
|
], JSON_UNESCAPED_SLASHES);
|
|
|
|
$script = <<<'BASH'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
INSTALL_DIR=/opt/truckwash-edge-agent
|
|
mkdir -p "$INSTALL_DIR"
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update
|
|
apt-get install -y curl ca-certificates nodejs npm python3 make g++
|
|
curl -fsSL "__PACKAGE_URL__" -o "$INSTALL_DIR/package.json"
|
|
curl -fsSL "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs"
|
|
cat > "$INSTALL_DIR/config.json" <<'EOF_JSON'
|
|
__CONFIG_JSON__
|
|
EOF_JSON
|
|
cd "$INSTALL_DIR"
|
|
npm install --omit=dev
|
|
cat >/etc/systemd/system/truckwash-edge-agent.service <<'EOF'
|
|
[Unit]
|
|
Description=TruckWash Edge Agent
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=/opt/truckwash-edge-agent
|
|
ExecStart=/usr/bin/node /opt/truckwash-edge-agent/agent.mjs --config /opt/truckwash-edge-agent/config.json
|
|
Restart=always
|
|
RestartSec=5
|
|
User=root
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl enable --now truckwash-edge-agent.service
|
|
echo 'TruckWash edge agent installed.'
|
|
BASH;
|
|
|
|
return strtr($script, [
|
|
'__PACKAGE_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/package.json',
|
|
'__AGENT_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/agent.mjs',
|
|
'__CONFIG_JSON__' => (string)$configJson,
|
|
]);
|
|
}
|
|
|
|
public function buildBrowserShellWsUrl(string $sessionToken): ?string
|
|
{
|
|
$brokerUrl = $this->getBrokerPublicUrl();
|
|
if ($brokerUrl === null) {
|
|
return null;
|
|
}
|
|
|
|
$parsed = parse_url($brokerUrl);
|
|
if (!is_array($parsed) || !isset($parsed['host'])) {
|
|
return null;
|
|
}
|
|
|
|
$scheme = (($parsed['scheme'] ?? 'http') === 'https') ? 'wss' : 'ws';
|
|
$url = $scheme . '://' . $parsed['host'];
|
|
if (isset($parsed['port'])) {
|
|
$url .= ':' . $parsed['port'];
|
|
}
|
|
$url .= '/ws/browser-shell?token=' . urlencode($sessionToken);
|
|
|
|
return $url;
|
|
}
|
|
|
|
public function getBrokerPublicUrl(): ?string
|
|
{
|
|
$configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: ''));
|
|
if ($configured !== '') {
|
|
return $this->normalizeBrokerPublicUrl($configured);
|
|
}
|
|
|
|
$apiBaseUrl = $this->getApiBaseUrl();
|
|
$parsed = parse_url($apiBaseUrl);
|
|
if (!is_array($parsed) || !isset($parsed['host'])) {
|
|
return null;
|
|
}
|
|
|
|
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'https' : 'http';
|
|
$port = getenv('EDGE_BROKER_PUBLIC_PORT') ?: '4300';
|
|
return $this->normalizeBrokerPublicUrl($scheme . '://' . $parsed['host'] . ':' . $port);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private function normalizeBrokerPublicUrl(string $url): string
|
|
{
|
|
$parsed = parse_url($url);
|
|
if (!is_array($parsed) || !isset($parsed['host'])) {
|
|
return trim($url);
|
|
}
|
|
|
|
$host = (string)$parsed['host'];
|
|
$scheme = strtolower((string)($parsed['scheme'] ?? ''));
|
|
if ($scheme === '' || ($scheme === 'http' && $this->shouldUseSecureBrokerScheme($host))) {
|
|
$scheme = $this->shouldUseSecureBrokerScheme($host) ? 'https' : 'http';
|
|
}
|
|
|
|
$normalized = $scheme . '://' . $host;
|
|
if (isset($parsed['port'])) {
|
|
$normalized .= ':' . $parsed['port'];
|
|
}
|
|
if (isset($parsed['path'])) {
|
|
$normalized .= $parsed['path'];
|
|
}
|
|
if (isset($parsed['query'])) {
|
|
$normalized .= '?' . $parsed['query'];
|
|
}
|
|
if (isset($parsed['fragment'])) {
|
|
$normalized .= '#' . $parsed['fragment'];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function shouldUseSecureBrokerScheme(string $host): bool
|
|
{
|
|
$normalized = strtolower(trim($host, '[]'));
|
|
if ($normalized === '' || $normalized === 'localhost' || $normalized === 'edge-broker') {
|
|
return false;
|
|
}
|
|
|
|
if (str_ends_with($normalized, '.localhost')
|
|
|| str_ends_with($normalized, '.local')
|
|
|| str_ends_with($normalized, '.lan')
|
|
|| str_ends_with($normalized, '.internal')
|
|
|| str_ends_with($normalized, '.home.arpa')) {
|
|
return false;
|
|
}
|
|
|
|
if (filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
|
|
return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
|
}
|
|
|
|
return str_contains($normalized, '.');
|
|
}
|
|
|
|
/**
|
|
* @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()) {
|
|
throw new Exception('Edge gateway not found');
|
|
}
|
|
|
|
return $gateway;
|
|
}
|
|
|
|
/**
|
|
* @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 getPrimaryGatewayForDepartment(int $departmentId): 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 (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
|
|
throw new Exception('Department edge gateway is offline');
|
|
}
|
|
|
|
return $gateway;
|
|
}
|
|
|
|
private function createCommandJob(int $gatewayId, string $commandType, array $request, ?int $userId): edge_gateway_command_jobs_o
|
|
{
|
|
$jobObject = new edge_gateway_command_jobs_o();
|
|
$jobId = $jobObject->add_object([
|
|
'gateway_id' => $gatewayId,
|
|
'command_type' => $commandType,
|
|
'status' => 'PENDING',
|
|
'request_json' => $request,
|
|
'response_json' => [],
|
|
'correlation_id' => bin2hex(random_bytes(16)),
|
|
'requested_by' => $userId,
|
|
'requested_at' => $this->now(),
|
|
]);
|
|
|
|
return $jobObject->select($jobId);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function tryImmediateBrokerDispatch(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): void
|
|
{
|
|
$this->markCommandJobDispatching($job);
|
|
|
|
try {
|
|
$response = $this->broker()->dispatchCommand(
|
|
(int)$gateway->id,
|
|
(string)$job->command_type->value(),
|
|
$this->buildCommandExecutionPayload($job, $gateway)
|
|
);
|
|
|
|
$ok = (bool)($response['ok'] ?? false);
|
|
$payload = (array)($response['payload'] ?? []);
|
|
$errorMessage = $ok ? null : trim((string)($response['error'] ?? 'Edge broker command failed'));
|
|
|
|
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
|
|
|
|
if (!$ok) {
|
|
throw new Exception($errorMessage ?: 'Edge broker command failed');
|
|
}
|
|
} catch (\Throwable $throwable) {
|
|
if ($this->shouldFallbackToQueuedDelivery($throwable)) {
|
|
$this->releaseCommandJobToQueue($job);
|
|
return;
|
|
}
|
|
|
|
$this->finalizeCommandJob($job, false, [], $throwable->getMessage(), $gateway);
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
|
|
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,
|
|
error_message = NULL,
|
|
completed_at = NULL
|
|
WHERE id = :id'
|
|
);
|
|
$update->execute([
|
|
':status' => 'DISPATCHING',
|
|
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
|
':id' => (int)$row['id'],
|
|
]);
|
|
|
|
$pdo->commit();
|
|
} catch (\Throwable $throwable) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $throwable;
|
|
}
|
|
|
|
$job = (new edge_gateway_command_jobs_o())->select((int)$row['id']);
|
|
$this->markLinkedUpdateJobStarted((int)$job->id);
|
|
|
|
return $job;
|
|
}
|
|
|
|
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->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 {
|
|
$commandType = (string)$job->command_type->value();
|
|
|
|
if ($commandType === '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');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ($commandType === 'RUN_UPDATE') {
|
|
$this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage);
|
|
}
|
|
}
|
|
|
|
private function releaseCommandJobToQueue(edge_gateway_command_jobs_o $job): void
|
|
{
|
|
$job->response_json->set([]);
|
|
$job->error_message->set(null);
|
|
$job->completed_at->set(null);
|
|
$job->status->set('PENDING');
|
|
}
|
|
|
|
private function markCommandJobDispatching(edge_gateway_command_jobs_o $job): void
|
|
{
|
|
$job->response_json->set([]);
|
|
$job->error_message->set(null);
|
|
$job->completed_at->set(null);
|
|
$job->status->set('DISPATCHING');
|
|
}
|
|
|
|
private function shouldFallbackToQueuedDelivery(\Throwable $throwable): bool
|
|
{
|
|
if ($throwable instanceof edge_broker_transport_exception) {
|
|
return true;
|
|
}
|
|
|
|
if ($throwable instanceof edge_broker_http_exception) {
|
|
return $throwable->statusCode() === 503;
|
|
}
|
|
|
|
$message = $throwable->getMessage();
|
|
return str_contains($message, 'Gateway agent is offline')
|
|
|| str_contains($message, 'Could not resolve host:')
|
|
|| str_contains($message, 'Failed to connect')
|
|
|| str_contains($message, 'Connection refused');
|
|
}
|
|
|
|
private function markLinkedUpdateJobStarted(int $commandJobId): void
|
|
{
|
|
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
|
|
if ($updateJob === null) {
|
|
return;
|
|
}
|
|
|
|
if ($updateJob->started_at->value() === null) {
|
|
$updateJob->started_at->set($this->now());
|
|
}
|
|
}
|
|
|
|
private function finalizeLinkedUpdateJob(int $commandJobId, bool $ok, array $payload, ?string $errorMessage): void
|
|
{
|
|
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
|
|
if ($updateJob === null) {
|
|
return;
|
|
}
|
|
|
|
if ($updateJob->started_at->value() === null) {
|
|
$updateJob->started_at->set($this->now());
|
|
}
|
|
|
|
$updateJob->status->set($ok ? 'COMPLETED' : 'FAILED');
|
|
$updateJob->completed_at->set($this->now());
|
|
$updateJob->result_json->set($ok
|
|
? $payload
|
|
: ['error' => $errorMessage ?: 'Edge gateway update failed']);
|
|
}
|
|
|
|
private function findLinkedUpdateJobByCommandId(int $commandJobId): ?edge_gateway_update_jobs_o
|
|
{
|
|
$rows = (new edge_gateway_update_jobs_o())->getFieldsWhere([
|
|
'command_job_id' => $commandJobId,
|
|
'deleted_at' => null,
|
|
], ['id']);
|
|
|
|
if ($rows === []) {
|
|
return null;
|
|
}
|
|
|
|
return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
return $gateway;
|
|
}
|
|
|
|
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 broker(): edge_broker_client
|
|
{
|
|
return $this->brokerClient ?? new edge_broker_client();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|