feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20. WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes. WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
This commit is contained in:
+78
-7
@@ -41,10 +41,11 @@ function encodeCommand(args: string[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort SET. Resolves true on apparent success, false otherwise.
|
||||
* Never rejects.
|
||||
* Best-effort execution of a single RESP command (preceded by optional
|
||||
* AUTH/SELECT). Resolves true when the final reply is a non-error (`+...` or
|
||||
* `:...`), false otherwise. Never rejects.
|
||||
*/
|
||||
export function redisSet(key: string, value: string): Promise<boolean> {
|
||||
function runCommand(args: string[]): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return Promise.resolve(false);
|
||||
const target = parseRedisUrl(url);
|
||||
@@ -68,7 +69,7 @@ export function redisSet(key: string, value: string): Promise<boolean> {
|
||||
if (target.db !== undefined && target.db > 0) {
|
||||
commands.push(encodeCommand(['SELECT', String(target.db)]));
|
||||
}
|
||||
commands.push(encodeCommand(['SET', key, value]));
|
||||
commands.push(encodeCommand(args));
|
||||
|
||||
const socket = net.createConnection(
|
||||
{ host: target.host, port: target.port },
|
||||
@@ -85,16 +86,86 @@ export function redisSet(key: string, value: string): Promise<boolean> {
|
||||
socket.on('error', () => done(false));
|
||||
|
||||
let buf = '';
|
||||
let expectedReplies = commands.length;
|
||||
const expectedReplies = commands.length;
|
||||
socket.on('data', (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
// Count complete simple replies (lines terminated by \r\n).
|
||||
const lines = buf.split('\r\n').filter((l) => l.length > 0);
|
||||
if (lines.length >= expectedReplies) {
|
||||
const last = lines[lines.length - 1];
|
||||
// +OK for SET success.
|
||||
done(last.startsWith('+OK') || last.startsWith('+'));
|
||||
// +OK / +... (simple string) or :N (integer, e.g. DEL count) = success.
|
||||
done(last.startsWith('+') || last.startsWith(':'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort SET. When `ttlSeconds` is a positive integer the command is
|
||||
* issued as `SET key val EX <ttl>`, otherwise a plain `SET key val`. Resolves
|
||||
* true on apparent success, false otherwise. Never rejects. Backward
|
||||
* compatible: existing two-arg callers are unaffected.
|
||||
*/
|
||||
export function redisSet(
|
||||
key: string,
|
||||
value: string,
|
||||
ttlSeconds?: number,
|
||||
): Promise<boolean> {
|
||||
const args = ['SET', key, value];
|
||||
if (
|
||||
typeof ttlSeconds === 'number' &&
|
||||
Number.isFinite(ttlSeconds) &&
|
||||
ttlSeconds > 0
|
||||
) {
|
||||
args.push('EX', String(Math.floor(ttlSeconds)));
|
||||
}
|
||||
return runCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort DEL. Resolves true when Redis acknowledges the command (reply is
|
||||
* an integer, even 0), false otherwise. Never rejects.
|
||||
*/
|
||||
export function redisDel(key: string): Promise<boolean> {
|
||||
return runCommand(['DEL', key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL (seconds) written alongside the tunnel:active flag. Must match the edge
|
||||
* `tunnel-active` forward_auth gate's TTL_SECONDS (currently 30) so the flag
|
||||
* naturally expires in lock-step with the gate's cache. Overridable via
|
||||
* TUNNEL_ACTIVE_TTL; defaults to 30.
|
||||
*/
|
||||
function tunnelActiveTtl(): number {
|
||||
const raw = process.env.TUNNEL_ACTIVE_TTL;
|
||||
if (raw) {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return Math.floor(n);
|
||||
}
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live kill-switch primitive. Writes the SAME key the edge gate reads:
|
||||
* SET tunnel:active:<subdomain> <"1"|"0"> EX <TTL>
|
||||
* "1" = ALLOW, "0" = DENY. A currently-connected tunnel is dropped within ~1s
|
||||
* when "0" is written; "1" re-allows it. No-op (returns false) when REDIS_URL
|
||||
* is unset — behavior then falls back to the gate's Postgres `is_active` read,
|
||||
* exactly as before this wiring existed. Never throws.
|
||||
*/
|
||||
export async function setTunnelActive(
|
||||
subdomain: string,
|
||||
active: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!process.env.REDIS_URL) return false;
|
||||
if (!subdomain) return false;
|
||||
try {
|
||||
return await redisSet(
|
||||
`tunnel:active:${subdomain}`,
|
||||
active ? '1' : '0',
|
||||
tunnelActiveTtl(),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user