337 lines
14 KiB
PHP
337 lines
14 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\edge_gateway_manager;
|
|
use classes\response;
|
|
use objects\logs_o;
|
|
use traits\route_t;
|
|
|
|
class edgeGatewaysRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->get('/edge-gateways', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
|
if ($departmentId !== null && $departmentId > 0) {
|
|
$this->requireDepartmentAccess((int)$departmentId);
|
|
}
|
|
|
|
$response->success((new edge_gateway_manager())->listGateways($departmentId));
|
|
}, [
|
|
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
|
|
]);
|
|
|
|
$this->get('/edge-gateways/{id}', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
$response->success($gateway);
|
|
}, [
|
|
'modules_shelly_config' => 'View department edge gateway detail',
|
|
]);
|
|
|
|
$this->post('/edge-gateways/install-token', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
self::requireParameters(['department_id']);
|
|
$departmentId = (int)self::getParameter('department_id');
|
|
self::requireParameterIntPositive($departmentId, 'department_id');
|
|
$this->requireDepartmentAccess($departmentId);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success(
|
|
(new edge_gateway_manager())->createInstallToken(
|
|
$departmentId,
|
|
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
|
$user ? (int)$user->id : null
|
|
),
|
|
201
|
|
);
|
|
}, [
|
|
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
|
|
]);
|
|
|
|
$this->post('/edge-gateways/{id}/discovery', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$manager = new edge_gateway_manager();
|
|
$gateway = $manager->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success($manager->queueDiscovery($gatewayId, $user ? (int)$user->id : null));
|
|
}, [
|
|
'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway',
|
|
]);
|
|
|
|
$this->put('/edge-gateways/{id}/bindings', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
self::requireParameters(['bindings']);
|
|
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$manager = new edge_gateway_manager();
|
|
$gateway = $manager->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success($manager->setRelayBindings(
|
|
$gatewayId,
|
|
(array)self::getParameter('bindings'),
|
|
$user ? (int)$user->id : null
|
|
));
|
|
}, [
|
|
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
|
|
]);
|
|
|
|
$this->post('/edge-gateways/{id}/update-jobs', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
self::requireParameters(['target_version']);
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$manager = new edge_gateway_manager();
|
|
$gateway = $manager->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success($manager->queueUpdate(
|
|
$gatewayId,
|
|
(string)self::getParameter('target_version'),
|
|
self::isParametersSet(['release_channel']) ? (string)self::getParameter('release_channel') : edge_gateway_manager::DEFAULT_RELEASE_CHANNEL,
|
|
$user ? (int)$user->id : null
|
|
), 201);
|
|
}, [
|
|
'modules_shelly_config' => 'Queue an automatic edge gateway update',
|
|
]);
|
|
|
|
$this->post('/edge-gateways/{id}/shell-sessions', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
self::requireParameters(['reason']);
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$manager = new edge_gateway_manager();
|
|
$gateway = $manager->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
self::requireMinLength('reason', 5);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success($manager->createShellSession(
|
|
$gatewayId,
|
|
(string)self::getParameter('reason'),
|
|
$user ? (int)$user->id : null
|
|
), 201);
|
|
}, [
|
|
'modules_shelly_config' => 'Approve a break-glass root shell on an edge gateway',
|
|
]);
|
|
|
|
$this->post('/edge-gateways/{id}/rotate-credentials', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$manager = new edge_gateway_manager();
|
|
$gateway = $manager->getGateway($gatewayId);
|
|
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success($manager->rotateGatewayCredentials($gatewayId, $user ? (int)$user->id : null));
|
|
}, [
|
|
'modules_shelly_config' => 'Rotate edge gateway agent credentials',
|
|
]);
|
|
|
|
$this->post('/departments/{id}/gateway-cutover', function () {
|
|
global /** @var response $response */ $response;
|
|
$this->requirePermission('modules_shelly_config');
|
|
|
|
$departmentId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($departmentId, 'id');
|
|
$this->requireDepartmentAccess($departmentId);
|
|
self::requireParameters(['transport_mode']);
|
|
|
|
$user = (new authentication())->get_user();
|
|
$response->success((new edge_gateway_manager())->setDepartmentTransportMode(
|
|
$departmentId,
|
|
(string)self::getParameter('transport_mode'),
|
|
$user ? (int)$user->id : null
|
|
));
|
|
}, [
|
|
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
|
|
]);
|
|
|
|
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
|
$this->get('/edge-agent/artifacts/agent.mjs', fn() => $this->renderAgentArtifact('agent.mjs', 'application/javascript; charset=utf-8'));
|
|
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
|
|
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
|
|
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
|
|
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
|
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
|
$this->post('/edge-agent/internal/agent/auth', fn() => $this->handleInternalAgentAuth());
|
|
$this->post('/edge-agent/internal/shell/auth', fn() => $this->handleInternalShellAuth());
|
|
$this->post('/edge-agent/internal/shell-sessions/{id}/close', fn() => $this->handleInternalShellClose());
|
|
}
|
|
|
|
private function renderInstallScript(): void
|
|
{
|
|
$manager = new edge_gateway_manager();
|
|
$token = trim((string)$this->fromQuery('token'));
|
|
if ($token === '') {
|
|
http_response_code(400);
|
|
echo 'Missing token';
|
|
exit;
|
|
}
|
|
|
|
header('Content-Type: text/x-shellscript; charset=utf-8');
|
|
echo $manager->buildInstallScript($token);
|
|
exit;
|
|
}
|
|
|
|
private function renderAgentArtifact(string $fileName, string $contentType): void
|
|
{
|
|
$artifactPath = dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName;
|
|
if (!is_file($artifactPath)) {
|
|
http_response_code(404);
|
|
echo 'Missing edge agent artifact';
|
|
exit;
|
|
}
|
|
|
|
header('Content-Type: ' . $contentType);
|
|
echo file_get_contents($artifactPath);
|
|
exit;
|
|
}
|
|
|
|
private function handleAgentClaim(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
self::requireParameters(['token']);
|
|
$payload = self::getParametersAsArray();
|
|
$response->success((new edge_gateway_manager())->claimGateway(
|
|
(string)$payload['token'],
|
|
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
|
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
|
(array)($payload['metadata'] ?? [])
|
|
), 201);
|
|
}
|
|
|
|
private function handleAgentHeartbeat(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$payload = self::getParametersAsArray();
|
|
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
if ($token === '') {
|
|
$response->error('Missing edge gateway agent token', 401);
|
|
}
|
|
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload));
|
|
}
|
|
|
|
private function handleAgentCommandPoll(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
$payload = self::getParametersAsArray();
|
|
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
if ($token === '') {
|
|
$response->error('Missing edge gateway agent token', 401);
|
|
}
|
|
|
|
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS;
|
|
$response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds));
|
|
}
|
|
|
|
private function handleAgentCommandResult(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$gatewayId = (int)$this->fromRoute('id');
|
|
$jobId = (int)$this->fromRoute('jobId');
|
|
self::requireParameterIntPositive($gatewayId, 'id');
|
|
self::requireParameterIntPositive($jobId, 'jobId');
|
|
|
|
$payload = self::getParametersAsArray();
|
|
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
|
if ($token === '') {
|
|
$response->error('Missing edge gateway agent token', 401);
|
|
}
|
|
|
|
$response->success((new edge_gateway_manager())->submitCommandResult(
|
|
$gatewayId,
|
|
$jobId,
|
|
$token,
|
|
(bool)($payload['ok'] ?? false),
|
|
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
|
|
isset($payload['error']) ? (string)$payload['error'] : null
|
|
));
|
|
}
|
|
|
|
private function handleInternalAgentAuth(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$this->requireInternalSecret();
|
|
self::requireParameters(['gatewayId', 'agentToken']);
|
|
$gateway = (new edge_gateway_manager())->authenticateGateway(
|
|
(int)self::getParameter('gatewayId'),
|
|
(string)self::getParameter('agentToken')
|
|
);
|
|
$response->success($gateway->asArray());
|
|
}
|
|
|
|
private function handleInternalShellAuth(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$this->requireInternalSecret();
|
|
self::requireParameters(['sessionToken']);
|
|
$response->success((new edge_gateway_manager())->validateShellSessionToken((string)self::getParameter('sessionToken')));
|
|
}
|
|
|
|
private function handleInternalShellClose(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$this->requireInternalSecret();
|
|
self::requireParameters(['sessionToken']);
|
|
$payload = self::getParametersAsArray();
|
|
$response->success((new edge_gateway_manager())->closeShellSession(
|
|
(string)$payload['sessionToken'],
|
|
(string)($payload['transcript'] ?? ''),
|
|
(string)($payload['closedReason'] ?? 'closed')
|
|
));
|
|
}
|
|
|
|
private function requireInternalSecret(): void
|
|
{
|
|
global /** @var response $response */ $response;
|
|
$expected = trim((string)(getenv('EDGE_INTERNAL_SECRET') ?: getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
|
if ($expected === '') {
|
|
return;
|
|
}
|
|
|
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
|
$provided = trim((string)($headers['X-Edge-Internal-Secret'] ?? $headers['X-Edge-Broker-Secret'] ?? $this->fromRequest('internal_secret') ?? ''));
|
|
if ($provided === '' || !hash_equals($expected, $provided)) {
|
|
$response->error('Forbidden', 403);
|
|
}
|
|
}
|
|
}
|