Add broker diagnostics and enhance relay logging

- Implement `diagnoseBrokerConfiguration` to validate broker URLs, shared secrets, and connection health.
- Add diagnostic methods for shared secret validation, including legacy sync support.
- Extend relay logging with descriptive context (`relay_name`, `relay_role`) and dynamic messaging.
- Update tests to cover broker health and shared secret diagnostics.
This commit is contained in:
Jeppe Bundgaard
2026-04-28 11:30:09 +02:00
parent c73e459d26
commit 55f8f25fd7
12 changed files with 711 additions and 14 deletions
+31 -2
View File
@@ -7,6 +7,7 @@ services:
- "${TRAEFIK_WEB_PORT:-80}:80"
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
- "${TRAEFIK_WEBSECURE_STAGING_PORT:-4433}:4433"
- "${TRAEFIK_EDGE_BROKER_PORT:-4300}:4300"
# Prometheus metrics endpoint (local dev)
- "${TRAEFIK_METRICS_PORT:-9100}:9100"
volumes:
@@ -34,6 +35,34 @@ services:
- "traefik.http.routers.traefik-local.entrypoints=web"
- "traefik.http.routers.traefik-local.service=api@internal"
- "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file"
# Broker API (handled in edge-broker service)
- "traefik.http.routers.edge-broker.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker.entrypoints=websecure"
- "traefik.http.routers.edge-broker.tls=true"
- "traefik.http.routers.edge-broker.tls.certresolver=le"
- "traefik.http.routers.edge-broker.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker.priority=200"
- "traefik.http.routers.edge-broker.service=edge-broker"
- "traefik.http.routers.edge-broker-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-io.tls=true"
- "traefik.http.routers.edge-broker-io.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-io.priority=200"
- "traefik.http.routers.edge-broker-io.service=edge-broker"
- "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-staging.tls=true"
- "traefik.http.routers.edge-broker-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-staging.priority=200"
- "traefik.http.routers.edge-broker-staging.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.priority=200"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
redis:
image: redis:7
@@ -203,7 +232,7 @@ services:
- php-staging
command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile-staging", "--adapter", "caddyfile"]
volumes:
- ./services/nginx/app:/var/www/html
- ./services/nginx/staging:/var/www/html
- ./services/caddy:/etc/caddy:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
@@ -359,7 +388,7 @@ services:
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/nginx/staging:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs-staging:/var/log/php
+29
View File
@@ -447,6 +447,35 @@ export function createBrokerServer(options = {}) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
if (req.method === "GET" && url.pathname === "/api/health") {
jsonResponse(res, 200, {
ok: true,
service: "edge-broker",
auth_mode: authMode,
manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size,
});
return;
}
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
return;
}
jsonResponse(res, 200, {
ok: true,
shared_secret_required: Boolean(sharedSecret),
});
return;
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
+42
View File
@@ -144,6 +144,48 @@ test("broker dispatches commands to connected agents", async () => {
await broker.close();
});
test("broker exposes health and shared-secret diagnostics", async () => {
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
const address = await broker.listen(0);
const port = address.port;
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const healthJson = await healthResponse.json();
assert.equal(healthResponse.status, 200);
assert.equal(healthJson.ok, true);
assert.equal(healthJson.service, "edge-broker");
assert.equal(healthJson.auth_mode, "manager");
assert.equal(healthJson.manager_url_configured, true);
assert.equal(healthJson.shared_secret_configured, true);
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
headers: {
"x-edge-broker-secret": "wrong-secret",
},
});
const invalidSecretJson = await invalidSecretResponse.json();
assert.equal(invalidSecretResponse.status, 403);
assert.equal(invalidSecretJson.ok, false);
assert.equal(invalidSecretJson.shared_secret_required, true);
const validSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
headers: {
"x-edge-broker-secret": "secret",
},
});
const validSecretJson = await validSecretResponse.json();
assert.equal(validSecretResponse.status, 200);
assert.equal(validSecretJson.ok, true);
assert.equal(validSecretJson.shared_secret_required, true);
await broker.close();
});
test("broker bridges browser shell sessions through the connected agent", async () => {
const closedSessions = [];
const broker = createBrokerServer({
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
namespace classes;
use Exception;
use objects\department_relays_o;
use objects\departments_o;
use objects\department_variables_o;
use objects\edge_gateway_audit_logs_o;
@@ -4025,6 +4026,342 @@ BASH;
return $secret !== null && hash_equals($configured, trim($secret));
}
/**
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public function diagnoseBrokerConfiguration(array $options = []): array
{
$target = strtolower(trim((string)($options['target'] ?? 'all')));
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl(
array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl()
);
$publicConfigured = array_key_exists('public_broker_url', $options)
? trim((string)$options['public_broker_url'])
: $this->configuredPublicBrokerUrl();
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
);
$sharedSecret = array_key_exists('broker_shared_secret', $options)
? trim((string)$options['broker_shared_secret'])
: $this->configuredBrokerSharedSecret();
$diagnostics = [
'target' => $target,
'checked_at' => $this->now(),
'broker_auth_mode' => array_key_exists('broker_auth_mode', $options)
? trim((string)$options['broker_auth_mode'])
: $this->configuredBrokerAuthMode(),
'broker_shared_secret_configured' => $sharedSecret !== '',
];
if ($target === 'internal' || $target === 'all') {
$diagnostics['internal_broker_connection'] = $this->diagnoseBrokerHttpEndpoint(
$internalUrl,
'Internal broker'
);
}
if ($target === 'public' || $target === 'all') {
$diagnostics['public_broker_url'] = $this->diagnoseBrokerHttpEndpoint(
$publicUrl,
'Public broker'
);
}
if ($target === 'secret' || $target === 'all') {
$diagnostics['broker_shared_secret'] = $this->diagnoseBrokerSharedSecret(
$internalUrl,
$sharedSecret
);
}
return $diagnostics;
}
private function deriveBrokerPublicUrl(): ?string
{
$apiBaseUrl = $this->getApiBaseUrl();
if (trim($apiBaseUrl) === '') {
return null;
}
return rtrim($apiBaseUrl, '/') . '/edge-broker';
}
/**
* @return array{url:?string,error:?string}
*/
private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array
{
$url = trim((string)$value);
if ($url === '') {
return [
'url' => null,
'error' => 'not_configured',
];
}
$parsed = parse_url($url);
$scheme = is_array($parsed) ? strtolower((string)($parsed['scheme'] ?? '')) : '';
$host = is_array($parsed) ? trim((string)($parsed['host'] ?? '')) : '';
if (!is_array($parsed) || $host === '' || !in_array($scheme, ['http', 'https'], true)) {
return [
'url' => $url,
'error' => 'invalid_url',
];
}
return [
'url' => rtrim($url, '/'),
'error' => null,
];
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @return array<string,mixed>
*/
private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array
{
if ($baseUrl['url'] === null || $baseUrl['error'] !== null) {
return $this->brokerDiagnosticUrlFailure($baseUrl, $label);
}
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
return array_merge($health, [
'ok' => true,
'status' => 'connected',
'url' => $baseUrl['url'],
'message' => $label . ' responded to the health check.',
]);
}
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
return array_merge($health, [
'ok' => true,
'status' => 'connected_legacy',
'url' => $baseUrl['url'],
'message' => $label . ' responded, but the health endpoint is not deployed yet.',
]);
}
if (($health['status_code'] ?? null) !== null) {
return array_merge($health, [
'ok' => false,
'status' => 'unexpected_response',
'url' => $baseUrl['url'],
'message' => $label . ' returned HTTP ' . (string)$health['status_code'] . ' instead of the broker health response.',
]);
}
return array_merge($health, [
'ok' => false,
'status' => 'unreachable',
'url' => $baseUrl['url'],
'message' => $label . ' did not respond.',
]);
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @return array<string,mixed>
*/
private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array
{
if ($baseUrl['url'] === null || $baseUrl['error'] !== null) {
return $this->brokerDiagnosticUrlFailure($baseUrl, 'Internal broker');
}
$headers = $sharedSecret !== '' ? ['x-edge-broker-secret: ' . $sharedSecret] : [];
$diagnostic = $this->brokerHttpProbe(
$baseUrl['url'] . '/api/diagnostics/shared-secret',
'POST',
[],
$headers
);
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
return array_merge($diagnostic, [
'ok' => true,
'status' => $required ? 'validated' : 'not_required',
'url' => $baseUrl['url'],
'message' => $required
? 'Broker accepted the configured shared secret.'
: 'Broker responded and does not currently require a shared secret.',
]);
}
if (($diagnostic['status_code'] ?? null) === 403) {
return array_merge($diagnostic, [
'ok' => false,
'status' => 'secret_rejected',
'url' => $baseUrl['url'],
'message' => 'Broker rejected the configured shared secret.',
]);
}
if (($diagnostic['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($diagnostic)) {
return $this->diagnoseBrokerSharedSecretWithLegacySync($baseUrl, $headers);
}
if (($diagnostic['status_code'] ?? null) !== null) {
return array_merge($diagnostic, [
'ok' => false,
'status' => 'unexpected_response',
'url' => $baseUrl['url'],
'message' => 'Broker returned HTTP ' . (string)$diagnostic['status_code'] . ' during shared secret validation.',
]);
}
return array_merge($diagnostic, [
'ok' => false,
'status' => 'unreachable',
'url' => $baseUrl['url'],
'message' => 'Internal broker did not respond during shared secret validation.',
]);
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @param array<int,string> $headers
* @return array<string,mixed>
*/
private function diagnoseBrokerSharedSecretWithLegacySync(array $baseUrl, array $headers): array
{
$legacy = $this->brokerHttpProbe(
$baseUrl['url'] . '/api/gateways/0/sync',
'POST',
['diagnostic' => true],
$headers
);
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
return array_merge($legacy, [
'ok' => true,
'status' => 'validated_legacy',
'url' => $baseUrl['url'],
'message' => 'Broker accepted the shared secret through the legacy sync endpoint.',
]);
}
if (($legacy['status_code'] ?? null) === 403) {
return array_merge($legacy, [
'ok' => false,
'status' => 'secret_rejected',
'url' => $baseUrl['url'],
'message' => 'Broker rejected the configured shared secret.',
]);
}
return array_merge($legacy, [
'ok' => false,
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
'url' => $baseUrl['url'],
'message' => 'Broker shared secret could not be validated.',
]);
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @return array<string,mixed>
*/
private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array
{
$error = (string)($baseUrl['error'] ?? 'not_configured');
return [
'ok' => false,
'status' => $error,
'url' => $baseUrl['url'],
'status_code' => null,
'elapsed_ms' => 0,
'message' => $error === 'invalid_url'
? $label . ' URL is not a valid http(s) URL.'
: $label . ' URL is not configured.',
];
}
/**
* @param array<string,mixed> $response
*/
private function isBrokerNotFoundProbe(array $response): bool
{
$json = isset($response['json']) && is_array($response['json']) ? (array)$response['json'] : [];
return strtolower(trim((string)($json['error'] ?? ''))) === 'not found';
}
/**
* @param array<int,string> $headers
* @return array<string,mixed>
*/
private function brokerHttpProbe(
string $url,
string $method = 'GET',
?array $payload = null,
array $headers = [],
int $timeoutSeconds = 3
): array {
$method = strtoupper(trim($method)) ?: 'GET';
$requestHeaders = array_filter(array_merge(['Accept: application/json'], $headers));
$options = [
'method' => $method,
'header' => implode("\r\n", $requestHeaders),
'timeout' => max(1, $timeoutSeconds),
'ignore_errors' => true,
];
if ($payload !== null) {
$requestHeaders[] = 'Content-Type: application/json';
$options['header'] = implode("\r\n", array_filter($requestHeaders));
$options['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$context = stream_context_create(['http' => $options]);
$started = microtime(true);
$body = @file_get_contents($url, false, $context);
$elapsedMs = (int)round((microtime(true) - $started) * 1000);
$responseHeaders = is_array($http_response_header ?? null) ? $http_response_header : [];
$statusCode = $this->parseHttpStatusCode($responseHeaders);
if ($body === false) {
$lastError = error_get_last();
return [
'status_code' => $statusCode,
'elapsed_ms' => $elapsedMs,
'error' => isset($lastError['message']) ? self::trimInstallSessionText($lastError['message'], 512) : null,
'json' => null,
'body_excerpt' => null,
];
}
$decoded = json_decode($body, true);
return [
'status_code' => $statusCode,
'elapsed_ms' => $elapsedMs,
'error' => null,
'json' => is_array($decoded) ? $decoded : null,
'body_excerpt' => self::trimInstallSessionText($body, 512),
];
}
/**
* @param array<int,string> $headers
*/
private function parseHttpStatusCode(array $headers): ?int
{
foreach ($headers as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', trim((string)$header), $matches) === 1) {
return (int)$matches[1];
}
}
return null;
}
/**
* @throws Exception
*/
@@ -4406,6 +4743,14 @@ BASH;
if ($relayId !== '' && !isset($signal['relay_id'])) {
$signal['relay_id'] = $relayId;
}
$relayRole = $this->normalizeRelayLogRole(
$actionContext['relay_role']
?? $actionContext['role']
?? $binding['metadata']['relay_role']
?? $binding['metadata']['role']
?? null
);
$relayName = $this->resolveRelayLogDisplayName($binding, $actionContext, $signal);
$context = [
'success' => $success,
@@ -4418,6 +4763,8 @@ BASH;
'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null,
'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null,
'relay_id' => $relayId !== '' ? $relayId : null,
'relay_name' => $relayName,
'relay_role' => $relayRole,
'action' => strtoupper(trim((string)($dispatchLog['action'] ?? 'RELAY'))),
'target_on' => array_key_exists('target_on', $dispatchLog) ? $dispatchLog['target_on'] : null,
'toggle_after_seconds' => $dispatchLog['toggle_after_seconds'] ?? null,
@@ -4453,42 +4800,211 @@ BASH;
$extraContext['admin_user_id'],
$extraContext['customer_user_id'],
$extraContext['customer_number'],
$extraContext['subuser_id']
$extraContext['subuser_id'],
$extraContext['relay_name'],
$extraContext['relay_label'],
$extraContext['relay_role'],
$extraContext['role']
);
if ($extraContext !== []) {
$context['action_context'] = $this->sanitizeRelayLogValue($extraContext);
}
$context = $this->compactRelayLogArray($context);
$description = $this->buildRelayLogDescription($context, $success);
if ($description !== '') {
$context['description'] = $description;
}
return $this->compactRelayLogArray($context);
}
private function buildRelayLogMessage(array $context, bool $success): string
{
$action = strtoupper(trim((string)($context['action'] ?? 'RELAY')));
$relayId = trim((string)($context['relay_id'] ?? 'unknown'));
$description = trim((string)($context['description'] ?? ''));
if ($description !== '') {
return $description;
}
return $this->buildRelayLogDescription($context, $success);
}
private function buildRelayLogDescription(array $context, bool $success): string
{
$subject = $this->buildRelayLogSubject($context);
$handler = strtolower(trim((string)($context['handler'] ?? 'local')));
$channel = trim((string)($context['delivery_channel'] ?? ''));
if (!$success) {
return sprintf('Relay %s %s failed via %s', $action, $relayId, $handler);
return trim(sprintf('%s failed via %s', $subject, $handler));
}
return trim(sprintf(
'Relay %s %s handled by %s%s',
$action,
$relayId,
'%s handled by %s%s',
$subject,
$handler,
$channel !== '' ? ' via ' . $channel : ''
));
}
private function buildRelayLogSubject(array $context): string
{
$action = strtoupper(trim((string)($context['action'] ?? 'RELAY')));
$relayId = trim((string)($context['relay_id'] ?? 'unknown'));
$relayName = trim((string)($context['relay_name'] ?? ''));
$relayTarget = $relayName !== '' ? $relayName : $relayId;
$relayRole = $this->normalizeRelayLogRole($context['relay_role'] ?? null);
$state = $this->resolveRelayLogState($context);
if (in_array($relayRole, ['ENTRY', 'EXIT'], true)) {
$verb = $state === false ? 'Close' : 'Open';
return trim(sprintf('%s %s %s', $verb, $relayRole, $relayTarget));
}
if (in_array($relayRole, ['MACHINE', 'PROGRAM_PICKER', 'CLEANER'], true) && $state !== null) {
return trim(sprintf('%s %s %s', $relayRole, $state ? 'ON' : 'OFF', $relayTarget));
}
$targetState = $state !== null ? ($state ? ' ON' : ' OFF') : '';
return trim(sprintf('Relay %s %s%s', $action, $relayTarget, $targetState));
}
private function resolveRelayLogState(array $context): ?bool
{
if (array_key_exists('target_on', $context) && $context['target_on'] !== null) {
return (bool)$context['target_on'];
}
$signal = isset($context['signal']) && is_array($context['signal']) ? (array)$context['signal'] : [];
$request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : [];
if (array_key_exists('on', $request)) {
return (bool)$request['on'];
}
$response = isset($context['response']) && is_array($context['response']) ? (array)$context['response'] : [];
if (array_key_exists('on', $response)) {
return (bool)$response['on'];
}
return null;
}
private function normalizeRelayLogRole(mixed $role): ?string
{
$normalized = strtoupper(trim((string)($role ?? '')));
if ($normalized === '') {
return null;
}
return match ($normalized) {
'ENTRANCE', 'IN', 'INLET', 'ENTRY_GATE' => 'ENTRY',
'OUT', 'OUTLET', 'EXIT_GATE' => 'EXIT',
'MACHINE_PROGRAM_PICKER', 'PROGRAM_SELECTOR', 'PICKER' => 'PROGRAM_PICKER',
'MACHINE_CLEANER' => 'CLEANER',
default => $normalized,
};
}
private function resolveRelayLogDisplayName(array $binding, array $actionContext, array $signal): ?string
{
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
$request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : [];
$relayId = trim((string)($signal['relay_id'] ?? $binding['relay_id'] ?? $request['relayId'] ?? $request['id'] ?? ''));
$directName = $this->firstRelayLogString([
$actionContext['relay_name'] ?? null,
$actionContext['relay_label'] ?? null,
$metadata['relay_name'] ?? null,
$metadata['relay_label'] ?? null,
$metadata['name'] ?? null,
$metadata['label'] ?? null,
$request['relay_name'] ?? null,
$request['relay_label'] ?? null,
]);
if ($directName !== null) {
return $directName;
}
$departmentName = $this->findDepartmentRelayName(
isset($binding['department_id']) ? (int)$binding['department_id'] : 0,
$relayId
);
if ($departmentName !== null) {
return $departmentName;
}
foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
$optionRelayId = trim((string)($option['id'] ?? ''));
if ($optionRelayId === '' || $optionRelayId !== $relayId) {
continue;
}
$optionName = $this->firstRelayLogString([
$option['name'] ?? null,
$option['label'] ?? null,
$option['device_name'] ?? null,
]);
if ($optionName !== null) {
return $optionName;
}
}
return null;
}
private function firstRelayLogString(array $candidates): ?string
{
foreach ($candidates as $candidate) {
$value = trim((string)($candidate ?? ''));
if ($value !== '') {
return $value;
}
}
return null;
}
private function findDepartmentRelayName(int $departmentId, string $relayId): ?string
{
if ($departmentId <= 0 || trim($relayId) === '') {
return null;
}
try {
$rows = (new department_relays_o())->getFieldsWhere([
'department' => $departmentId,
'relay_id' => $relayId,
'deleted_at' => null,
], ['id', 'name']);
} catch (\Throwable) {
return null;
}
foreach ($rows as $row) {
$name = trim((string)($row['name'] ?? ''));
if ($name !== '') {
return $name;
}
}
return null;
}
private function relayLogBindingPayload(array $binding): array
{
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
return $this->compactRelayLogArray([
'id' => isset($binding['id']) ? (int)$binding['id'] : null,
'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null,
'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null,
'relay_id' => $binding['relay_id'] ?? null,
'relay_name' => $this->firstRelayLogString([
$metadata['relay_name'] ?? null,
$metadata['relay_label'] ?? null,
$metadata['name'] ?? null,
$metadata['label'] ?? null,
]),
'device_id' => $binding['device_id'] ?? null,
'local_ip' => $binding['local_ip'] ?? null,
'channel' => isset($binding['channel']) ? (int)$binding['channel'] : null,
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\edge_gateway_manager;
use classes\edgegateway;
use classes\response;
use objects\logs_o;
@@ -20,6 +21,9 @@ class edgeGatewayConfigRoute
$this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [
'modules_shelly_config' => 'Update edge gateway config',
]);
$this->post('/edgegateway/config/broker-diagnostics', fn() => $this->handleBrokerDiagnostics(), [
'modules_shelly_config' => 'Test edge gateway broker configuration',
]);
}
private function handleGetConfig(): void
@@ -53,4 +57,21 @@ class edgeGatewayConfigRoute
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config');
$response->success((new edgegateway())->config->postConfigRequest());
}
private function handleBrokerDiagnostics(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$payload = self::getParametersAsArray();
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config');
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload));
}
}
@@ -101,7 +101,7 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
'level' => 'INFO',
'stream' => 'relay',
'source' => 'RELAY_DISPATCH',
'message' => 'Relay SWITCH M-7 handled by local via BROKER_FAST_PATH',
'message' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH',
'context' => [
'module' => 'selfserve',
'module_responsible' => 'selfserve',
@@ -109,6 +109,9 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
'handler' => 'local',
'delivery_channel' => 'BROKER_FAST_PATH',
'relay_id' => 'M-7',
'relay_name' => 'Roskilde Maskine',
'relay_role' => 'MACHINE',
'description' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH',
'associated' => [
'admin_user_id' => 77,
'customer_number' => 700123,
@@ -255,17 +258,21 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? []))
->toContain('Broker forwarded a live gateway log.');
expect(collect_gateway_messages($logsPage->data()['relay_logs'] ?? []))
->toContain('Relay SWITCH M-7 handled by local via BROKER_FAST_PATH');
->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH');
expect($logsPage->data()['relay_logs'][0]['context']['associated']['customer_number'] ?? null)
->toBe(700123)
->and($logsPage->data()['relay_logs'][0]['context']['module_responsible'] ?? null)
->toBe('selfserve')
->and($logsPage->data()['relay_logs'][0]['context']['relay_name'] ?? null)
->toBe('Roskilde Maskine')
->and($logsPage->data()['relay_logs'][0]['context']['relay_role'] ?? null)
->toBe('MACHINE')
->and($logsPage->data()['relay_logs'][0]['context']['reason'] ?? null)
->toBe('Broker relayed machine start');
expect(collect_gateway_messages($logsPage->data()['timeline'] ?? []))
->toContain('GATEWAY_SHELL_SESSION_OPENED')
->toContain('GATEWAY_SHELL_SESSION_CLOSED')
->toContain('Relay SWITCH M-7 handled by local via BROKER_FAST_PATH');
->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH');
expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null)
->toBe("edge-broker-shell\n");
@@ -89,3 +89,42 @@ it('stores broker settings in edge gateway module config and uses them for shell
->and($shellSession->data()['diagnostics']['broker_auth_mode'] ?? null)
->toBe('manager');
});
it('returns broker diagnostics for the current edge gateway module config values', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Gateway Diagnostics Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
api_fixtures()->setModuleConfig('edgegateway', 'enabled', 'true', 'bool');
api_fixtures()->setModuleConfig('edgegateway', 'broker_url', 'http://127.0.0.1:1', 'string');
api_fixtures()->setModuleConfig('edgegateway', 'public_broker_url', 'http://127.0.0.1:1/edge-broker', 'string');
api_fixtures()->setModuleConfig('edgegateway', 'broker_auth_mode', 'manager', 'string');
api_fixtures()->setModuleConfig('edgegateway', 'broker_shared_secret', 'diagnostic-secret', 'string');
$response = api_client()->post('/edgegateway/config/broker-diagnostics', [
'target' => 'all',
'broker_url' => 'http://127.0.0.1:1',
'public_broker_url' => 'http://127.0.0.1:1/edge-broker',
'broker_auth_mode' => 'manager',
'broker_shared_secret' => 'diagnostic-secret',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toHaveKey('internal_broker_connection')
->toHaveKey('public_broker_url')
->toHaveKey('broker_shared_secret')
->toHaveKey('broker_auth_mode', 'manager')
->toHaveKey('broker_shared_secret_configured', true)
->and($response->data()['internal_broker_connection']['ok'] ?? null)
->toBeFalse()
->and($response->data()['public_broker_url']['ok'] ?? null)
->toBeFalse()
->and($response->data()['broker_shared_secret']['ok'] ?? null)
->toBeFalse();
});
@@ -233,6 +233,8 @@ it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle
[
'module' => 'selfserve',
'reason' => 'Integration relay start',
'relay_name' => 'Roskilde Maskine',
'relay_role' => 'MACHINE',
'customer_number' => 700123,
'actor' => [
'admin_user_id' => 42,
@@ -269,9 +271,15 @@ it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
->toContain('Integration log line');
expect(edge_gateway_integration_messages($logs['relay_logs'] ?? []))
->toContain('Relay SWITCH M-7 handled by cloud via CLOUD');
->toContain('MACHINE ON Roskilde Maskine handled by cloud via CLOUD');
expect($logs['relay_logs'][0]['context']['module_responsible'] ?? null)
->toBe('selfserve')
->and($logs['relay_logs'][0]['context']['description'] ?? null)
->toBe('MACHINE ON Roskilde Maskine handled by cloud via CLOUD')
->and($logs['relay_logs'][0]['context']['relay_name'] ?? null)
->toBe('Roskilde Maskine')
->and($logs['relay_logs'][0]['context']['relay_role'] ?? null)
->toBe('MACHINE')
->and($logs['relay_logs'][0]['context']['associated']['customer_number'] ?? null)
->toBe(700123)
->and($logs['relay_logs'][0]['context']['associated']['admin_user_id'] ?? null)
@@ -27,7 +27,9 @@ it('registers edge gateway config endpoints from the module route directory', fu
expect($route)->not->toBeFalse();
expect($route)->toContain("'/edgegateway/config'");
expect($route)->toContain("'/edgegateway/config/broker-diagnostics'");
expect($route)->toContain('new edgegateway()');
expect($route)->toContain('new edge_gateway_manager()');
expect($legacyRoute)->not->toContain("'/edgegateway/config'");
});
+2
View File
@@ -3,6 +3,8 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
edge-broker:
address: ":4300"
certificatesResolvers:
le:
+2
View File
@@ -3,6 +3,8 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
edge-broker:
address: ":4300"
websecure-staging:
address: ":4433"
metrics: