fix(mqtt): verify broker ACL reload acknowledgement

This commit is contained in:
gadgethd
2026-08-09 02:32:09 +00:00
parent 395baa2581
commit 8981979e22
5 changed files with 262 additions and 21 deletions
+7
View File
@@ -17,6 +17,13 @@ export const mqttMessagesTotal = new Counter({
registers: [metricsRegistry],
});
export const ownerAclReloadTotal = new Counter({
name: 'meshcore_owner_acl_reload_total',
help: 'Authenticated Mosquitto ACL reload requests by bounded outcome.',
labelNames: ['outcome'] as const,
registers: [metricsRegistry],
});
export const websocketClients = new Gauge({
name: 'meshcore_websocket_clients',
help: 'Currently connected WebSocket clients.',
+58
View File
@@ -1,8 +1,11 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import test from 'node:test';
import { ownerAclReloadTotal } from '../metrics.js';
import {
getNodeIdsForUserInAcl,
parseAcl,
reloadMosquitto,
renderOwnerAcl,
updateUserAclContent,
userExistsInAclContent,
@@ -124,3 +127,58 @@ test('an explicit grant takes precedence over an unmanaged staging entry', () =>
assert.doesNotMatch(rendered.content, /topic read meshcore\/#/);
assert.deepEqual(rendered.semantic, [{ mqttUsername: 'hermes-test', nodeIds: [NODE_ID] }]);
});
async function reloadMetricValue(outcome: string): Promise<number> {
const metric = await ownerAclReloadTotal.get();
return metric.values.find((value) => value.labels['outcome'] === outcome)?.value ?? 0;
}
test('reload uses the authenticated contract and records acknowledged failures', async () => {
const requests: Array<{ authorization: string | undefined; body: string }> = [];
const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on('data', (chunk: Buffer) => chunks.push(chunk));
request.on('end', () => {
requests.push({
authorization: request.headers.authorization,
body: Buffer.concat(chunks).toString('utf8'),
});
response.writeHead(requests.length === 1 ? 204 : 504).end();
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
assert.ok(address && typeof address !== 'string');
const previousUrl = process.env['OWNER_ACL_RELOAD_URL'];
const previousToken = process.env['OWNER_ACL_RELOAD_TOKEN'];
const previousConsoleError = console.error;
const loggedErrors: unknown[][] = [];
process.env['OWNER_ACL_RELOAD_URL'] = `http://127.0.0.1:${address.port}/reload`;
process.env['OWNER_ACL_RELOAD_TOKEN'] = 'r'.repeat(32);
console.error = (...values: unknown[]) => loggedErrors.push(values);
try {
const successesBefore = await reloadMetricValue('success');
const failuresBefore = await reloadMetricValue('failure');
await reloadMosquitto();
await assert.rejects(reloadMosquitto(), /MOSQUITTO_RELOAD_FAILED:504/);
assert.deepEqual(requests, [
{ authorization: `Bearer ${'r'.repeat(32)}`, body: '{}' },
{ authorization: `Bearer ${'r'.repeat(32)}`, body: '{}' },
]);
assert.equal(await reloadMetricValue('success'), successesBefore + 1);
assert.equal(await reloadMetricValue('failure'), failuresBefore + 1);
assert.deepEqual(loggedErrors, [
['[owner-acl] mosquitto reload failed:', 'MOSQUITTO_RELOAD_FAILED:504'],
]);
} finally {
console.error = previousConsoleError;
if (previousUrl === undefined) delete process.env['OWNER_ACL_RELOAD_URL'];
else process.env['OWNER_ACL_RELOAD_URL'] = previousUrl;
if (previousToken === undefined) delete process.env['OWNER_ACL_RELOAD_TOKEN'];
else process.env['OWNER_ACL_RELOAD_TOKEN'] = previousToken;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
+25 -16
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { ownerAclReloadTotal } from '../metrics.js';
export const OWNER_ACL_RENDERER_VERSION = 'meshcore-owner-acl/v1';
const MANAGED_BEGIN = '# BEGIN MESHCORE OWNER ACL';
@@ -317,22 +318,30 @@ export function writeAclAtomically(
}
export async function reloadMosquitto(): Promise<void> {
const endpoint = process.env['OWNER_ACL_RELOAD_URL'] ?? 'http://mosquitto-reloader:8080/reload';
const token = String(process.env['OWNER_ACL_RELOAD_TOKEN'] ?? '');
if (token.length < 32) throw new Error('OWNER_ACL_RELOAD_TOKEN_INVALID');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: '{}',
redirect: 'error',
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
try {
const endpoint = process.env['OWNER_ACL_RELOAD_URL'] ?? 'http://mosquitto-reloader:8080/reload';
const token = String(process.env['OWNER_ACL_RELOAD_TOKEN'] ?? '');
if (token.length < 32) throw new Error('OWNER_ACL_RELOAD_TOKEN_INVALID');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: '{}',
redirect: 'error',
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`MOSQUITTO_RELOAD_FAILED:${response.status}`);
}
await response.body?.cancel().catch(() => undefined);
throw new Error(`MOSQUITTO_RELOAD_FAILED:${response.status}`);
ownerAclReloadTotal.inc({ outcome: 'success' });
} catch (error) {
ownerAclReloadTotal.inc({ outcome: 'failure' });
const message = error instanceof Error ? error.message : String(error);
console.error('[owner-acl] mosquitto reload failed:', message);
throw error;
}
await response.body?.cancel().catch(() => undefined);
}
+77 -5
View File
@@ -4,12 +4,15 @@
import hmac
import os
import signal
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOKEN = os.environ.get("OWNER_ACL_RELOAD_TOKEN", "")
LOG_PATH = os.environ.get("MOSQUITTO_LOG_PATH", "/mosquitto/log/mosquitto.log")
if len(TOKEN) < 32:
raise RuntimeError("OWNER_ACL_RELOAD_TOKEN must contain at least 32 characters")
RELOAD_ACK_TIMEOUT_SECONDS = float(os.environ.get("MOSQUITTO_RELOAD_ACK_TIMEOUT_SECONDS", "3"))
RELOAD_MARKER = b"Reloading config."
RELOAD_LOCK = threading.Lock()
def ensure_log_permissions() -> None:
@@ -19,6 +22,55 @@ def ensure_log_permissions() -> None:
os.chmod(LOG_PATH, 0o640, follow_symlinks=False)
def capture_log_cursor(log_path: str = LOG_PATH) -> tuple[int, int, int]:
metadata = os.stat(log_path, follow_symlinks=False)
return metadata.st_dev, metadata.st_ino, metadata.st_size
def wait_for_reload_ack(
cursor: tuple[int, int, int],
timeout_seconds: float = RELOAD_ACK_TIMEOUT_SECONDS,
log_path: str = LOG_PATH,
) -> bool:
"""Wait for a reload marker appended after cursor, tolerating log rotation."""
device, inode, offset = cursor
trailing = b""
deadline = time.monotonic() + timeout_seconds
while True:
try:
with open(log_path, "rb", buffering=0) as broker_log:
metadata = os.fstat(broker_log.fileno())
if (metadata.st_dev, metadata.st_ino) != (device, inode) or metadata.st_size < offset:
device, inode, offset = metadata.st_dev, metadata.st_ino, 0
trailing = b""
broker_log.seek(offset)
appended = broker_log.read()
if appended:
candidate = trailing + appended
if RELOAD_MARKER in candidate:
return True
trailing = candidate[-(len(RELOAD_MARKER) - 1):]
offset = broker_log.tell()
except FileNotFoundError:
# A rotating logger may briefly remove the path before recreating it.
device, inode, offset = -1, -1, 0
trailing = b""
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
time.sleep(min(0.05, remaining))
def signal_and_wait_for_reload(
log_path: str = LOG_PATH,
timeout_seconds: float = RELOAD_ACK_TIMEOUT_SECONDS,
) -> bool:
cursor = capture_log_cursor(log_path)
os.kill(1, signal.SIGHUP)
return wait_for_reload_ack(cursor, timeout_seconds, log_path)
class Handler(BaseHTTPRequestHandler):
server_version = "meshcore-mosquitto-reloader/1"
@@ -56,12 +108,32 @@ class Handler(BaseHTTPRequestHandler):
return
if length:
self.rfile.read(length)
os.kill(1, signal.SIGHUP)
try:
with RELOAD_LOCK:
ensure_log_permissions()
acknowledged = signal_and_wait_for_reload()
except (OSError, RuntimeError) as error:
print(f"[mosquitto-reloader] reload failed: {error}", flush=True)
self._send(503, b'{"error":"reload failed"}')
return
if not acknowledged:
print("[mosquitto-reloader] reload was not acknowledged by mosquitto", flush=True)
self._send(504, b'{"error":"reload not acknowledged"}')
return
self._send(204)
def log_message(self, fmt: str, *args: object) -> None:
print(f"[mosquitto-reloader] {self.address_string()} {fmt % args}", flush=True)
ensure_log_permissions()
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
def main() -> None:
if len(TOKEN) < 32:
raise RuntimeError("OWNER_ACL_RELOAD_TOKEN must contain at least 32 characters")
if not 0 < RELOAD_ACK_TIMEOUT_SECONDS < 5:
raise RuntimeError("MOSQUITTO_RELOAD_ACK_TIMEOUT_SECONDS must be between 0 and 5")
ensure_log_permissions()
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
import importlib.util
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).with_name("mosquitto-reloader.py")
SPEC = importlib.util.spec_from_file_location("mosquitto_reloader", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
reloader = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(reloader)
class ReloadAcknowledgementTests(unittest.TestCase):
def test_waits_for_marker_appended_after_cursor(self) -> None:
with tempfile.NamedTemporaryFile() as broker_log:
broker_log.write(b"old Reloading config.\n")
broker_log.flush()
cursor = reloader.capture_log_cursor(broker_log.name)
def append_ack() -> None:
time.sleep(0.02)
with open(broker_log.name, "ab") as output:
output.write(b"new Reloading config.\n")
writer = threading.Thread(target=append_ack)
writer.start()
self.assertTrue(reloader.wait_for_reload_ack(cursor, 0.5, broker_log.name))
writer.join()
def test_rejects_stale_marker(self) -> None:
with tempfile.NamedTemporaryFile() as broker_log:
broker_log.write(b"old Reloading config.\n")
broker_log.flush()
cursor = reloader.capture_log_cursor(broker_log.name)
self.assertFalse(reloader.wait_for_reload_ack(cursor, 0.02, broker_log.name))
class ReloadHttpContractTests(unittest.TestCase):
TOKEN = "t" * 32
def setUp(self) -> None:
reloader.TOKEN = self.TOKEN
self.server = reloader.ThreadingHTTPServer(("127.0.0.1", 0), reloader.Handler)
self.server_thread = threading.Thread(target=self.server.serve_forever)
self.server_thread.start()
host, port = self.server.server_address
self.url = f"http://{host}:{port}/reload"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.server_thread.join()
def request(self, token: str) -> int:
request = urllib.request.Request(
self.url,
data=b"{}",
method="POST",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=1) as response:
return response.status
except urllib.error.HTTPError as error:
try:
return error.code
finally:
error.close()
@mock.patch.object(reloader, "ensure_log_permissions")
@mock.patch.object(reloader, "signal_and_wait_for_reload", return_value=True)
def test_returns_204_only_after_acknowledgement(self, signal_reload: mock.Mock, _: mock.Mock) -> None:
self.assertEqual(self.request(self.TOKEN), 204)
signal_reload.assert_called_once_with()
@mock.patch.object(reloader, "ensure_log_permissions")
@mock.patch.object(reloader, "signal_and_wait_for_reload", return_value=False)
def test_returns_504_without_acknowledgement(self, signal_reload: mock.Mock, _: mock.Mock) -> None:
self.assertEqual(self.request(self.TOKEN), 504)
signal_reload.assert_called_once_with()
@mock.patch.object(reloader, "signal_and_wait_for_reload")
def test_rejects_invalid_bearer_without_signalling(self, signal_reload: mock.Mock) -> None:
self.assertEqual(self.request("x" * 32), 403)
signal_reload.assert_not_called()
if __name__ == "__main__":
unittest.main()