From 3d271f72ea5ee114c5771802b1fda12cb8b00ebf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:53:13 +0200 Subject: [PATCH] redis: say whether it still holds a consumer's ACL user The server keeps ACL users in memory only, so a restart forgets every consumer while the provisioner keeps running (hq issue 120). holds() checks ACL GETUSER for the user, enabled, with the mesh's password, so the harness makes a forgotten user again. Needs mesh-sdk 0.1.1. --- modules/redis/client.ts | 23 ++++++++++++++++++++++- modules/redis/package.json | 2 +- modules/redis/provisioner/index.ts | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/modules/redis/client.ts b/modules/redis/client.ts index 93a355d..3993842 100644 --- a/modules/redis/client.ts +++ b/modules/redis/client.ts @@ -8,7 +8,7 @@ // order requests were sent, which is what the queue below relies on. import { createConnection, type Socket } from "node:net"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; /** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */ @@ -113,6 +113,27 @@ export class RedisClient { await this.command("ACL", "DELUSER", username); } + /** + * Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks + * `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among + * them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its + * users live in memory and a restart forgets them. This is how the provisioner notices + * (novox/hq issue 120). + */ + async holdsAclUser(username: string, password: string): Promise { + const reply = await this.command("ACL", "GETUSER", username); + if (!Array.isArray(reply)) return false; + const field = (name: string): RespValue | undefined => { + const i = reply.indexOf(name); + return i >= 0 ? reply[i + 1] : undefined; + }; + const flags = field("flags"); + const passwords = field("passwords"); + if (!Array.isArray(flags) || !flags.includes("on")) return false; + if (!Array.isArray(passwords)) return false; + return passwords.includes(createHash("sha256").update(password).digest("hex")); + } + close(): void { if (this.socket) { this.socket.destroy(); diff --git a/modules/redis/package.json b/modules/redis/package.json index 7d32bdb..5e76d02 100644 --- a/modules/redis/package.json +++ b/modules/redis/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index c3ea7f7..84aea66 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -43,4 +43,11 @@ runProvisioner("redis-cache", { await redis.deleteAclUser(p.as); await announce("module.redis.cache.deprovisioned", { username: p.as }); }, + + // This server keeps its ACL users in memory only, so a restart of it forgets every consumer while + // this provisioner keeps running. Asked every minute, so a forgotten user is made again instead + // of every consumer failing to authenticate in silence (novox/hq issue 120). + async holds(p: Provision): Promise { + return redis.holdsAclUser(p.as, p.password); + }, });