Files
api/services/nginx/app/classes/edge_gateway_manager.php
T

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;
}
}