Require authorization for LAN worker relay endpoints

This commit is contained in:
Jeppe B
2026-06-01 23:43:23 +02:00
parent e3b38519fb
commit 1dc758a3a3
5 changed files with 96 additions and 4 deletions
@@ -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 {