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:
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+9
-1
@@ -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'");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ entryPoints:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
edge-broker:
|
||||
address: ":4300"
|
||||
|
||||
certificatesResolvers:
|
||||
le:
|
||||
|
||||
@@ -3,6 +3,8 @@ entryPoints:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
edge-broker:
|
||||
address: ":4300"
|
||||
websecure-staging:
|
||||
address: ":4433"
|
||||
metrics:
|
||||
|
||||
Reference in New Issue
Block a user