Require authorization for LAN worker relay endpoints
This commit is contained in:
@@ -61,7 +61,7 @@ final class OperationAbortException extends RuntimeException
|
||||
|
||||
final class HttpJsonClient
|
||||
{
|
||||
public function __construct(private readonly string $baseUrl)
|
||||
public function __construct(private readonly string $baseUrl, private readonly array $defaultHeaders = [])
|
||||
{
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ final class HttpJsonClient
|
||||
|
||||
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
|
||||
{
|
||||
$headers = ['Accept: application/json'];
|
||||
$headers = array_values(array_merge(['Accept: application/json'], $this->defaultHeaders));
|
||||
if ($payload !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
@@ -1031,7 +1031,10 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
|
||||
$this->workerHttp = new HttpJsonClient(
|
||||
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
|
||||
$this->workerAuthorizationHeaders()
|
||||
);
|
||||
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
||||
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
|
||||
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||
@@ -2615,6 +2618,12 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
}
|
||||
|
||||
private function workerAuthorizationHeaders(): array
|
||||
{
|
||||
$agentToken = trim((string)$this->config->get('agentToken', ''));
|
||||
return $agentToken !== '' ? ['X-Truckwash-Worker-Token: ' . $agentToken] : [];
|
||||
}
|
||||
|
||||
private function ensureAgentInstanceId(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
||||
|
||||
@@ -55,6 +55,7 @@ services:
|
||||
minio:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./config.json:/config/config.json:ro
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
@@ -20,6 +20,65 @@ function worker_read_json_body(): array
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
function worker_config_path(): string
|
||||
{
|
||||
$configuredPath = trim((string)getenv('TRUCKWASH_WORKER_CONFIG_PATH'));
|
||||
return $configuredPath !== '' ? $configuredPath : '/config/config.json';
|
||||
}
|
||||
|
||||
function worker_expected_token(): string
|
||||
{
|
||||
$environmentToken = trim((string)getenv('TRUCKWASH_WORKER_TOKEN'));
|
||||
if ($environmentToken !== '') {
|
||||
return $environmentToken;
|
||||
}
|
||||
|
||||
$configPath = worker_config_path();
|
||||
if (!is_file($configPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($configPath), true);
|
||||
return is_array($decoded) ? trim((string)($decoded['agentToken'] ?? '')) : '';
|
||||
}
|
||||
|
||||
function worker_request_token(): string
|
||||
{
|
||||
$headerToken = trim((string)($_SERVER['HTTP_X_TRUCKWASH_WORKER_TOKEN'] ?? ''));
|
||||
if ($headerToken !== '') {
|
||||
return $headerToken;
|
||||
}
|
||||
|
||||
$authorization = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||||
if (str_starts_with(strtolower($authorization), 'bearer ')) {
|
||||
return trim(substr($authorization, 7));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function worker_require_authorization(): bool
|
||||
{
|
||||
$expectedToken = worker_expected_token();
|
||||
if ($expectedToken === '') {
|
||||
worker_json_response(503, [
|
||||
'message' => 'LAN worker authorization is not configured',
|
||||
'error_code' => 'EDGE_GATEWAY_WORKER_AUTH_UNCONFIGURED',
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hash_equals($expectedToken, worker_request_token())) {
|
||||
worker_json_response(401, [
|
||||
'message' => 'Unauthorized',
|
||||
'error_code' => 'EDGE_GATEWAY_WORKER_UNAUTHORIZED',
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
@@ -124,6 +183,10 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/discover') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker_json_response(200, [
|
||||
'inventory' => [[
|
||||
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||
@@ -147,6 +210,10 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/status') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
|
||||
@@ -155,6 +222,10 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/input-status') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
$input = worker_fetch_shelly_input_state($localIp, $channel);
|
||||
@@ -166,6 +237,10 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/switch') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
$on = (bool)($body['on'] ?? false);
|
||||
|
||||
@@ -9,6 +9,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
|
||||
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
|
||||
$workerSource = file_get_contents(app_path('resources/edge-gateway-agent/lan-worker.php'));
|
||||
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
|
||||
$autoUpdaterDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.auto-updater'));
|
||||
|
||||
@@ -18,6 +19,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($agentSource)->not->toBeFalse();
|
||||
expect($edgeDockerfileSource)->not->toBeFalse();
|
||||
expect($workerDockerfileSource)->not->toBeFalse();
|
||||
expect($workerSource)->not->toBeFalse();
|
||||
expect($autoUpdaterSource)->not->toBeFalse();
|
||||
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
|
||||
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||
@@ -80,6 +82,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($composeSource)->toContain('<= 90');
|
||||
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
|
||||
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
|
||||
expect($composeSource)->toContain('./config.json:/config/config.json:ro');
|
||||
expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
|
||||
expect($composeSource)->not->toContain("curl\", \"-fsS\", \"http://127.0.0.1:9000/minio/health/live");
|
||||
expect($composeSource)->not->toContain('mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent');
|
||||
@@ -108,6 +111,8 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($workerDockerfileSource)->toContain('extension_loaded($extension)');
|
||||
expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
||||
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
|
||||
expect($workerSource)->toContain('worker_require_authorization');
|
||||
expect($agentSource)->toContain('X-Truckwash-Worker-Token: ');
|
||||
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
|
||||
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
|
||||
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev;');
|
||||
|
||||
@@ -32,7 +32,9 @@ it('wires local edge gateway machine ON signal monitor endpoints', function ():
|
||||
->and($agent)->toContain('pollMachineStartSignals')
|
||||
->and($agent)->toContain('/selfserve/machine-signal')
|
||||
->and($worker)->toContain('Input.GetStatus')
|
||||
->and($worker)->toContain('/relay/input-status');
|
||||
->and($worker)->toContain('/relay/input-status')
|
||||
->and($worker)->toContain('worker_require_authorization')
|
||||
->and($worker)->toContain('HTTP_X_TRUCKWASH_WORKER_TOKEN');
|
||||
});
|
||||
|
||||
it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void {
|
||||
|
||||
Reference in New Issue
Block a user