Files
linumiq_net-web_app/lib/redis.ts
T
Gerhard Scheikl c4aae01209 tunnel: clear stale kill-switch key on subdomain rename
When a user renames their tunnel subdomain, the upsert (onConflict=user_id)
overwrote the tunnels row in place, but the old subdomain's Redis
tunnel:active:<sub> flag was never cleared. The edge tunnel-active gate kept
serving the stale flag for up to its ~30s TTL, after which it fell back to a
Postgres miss and (currently) returned a misleading suspended response.

Capture the user's previous subdomain before the upsert and, when it changes,
DEL the old kill-switch key (new clearTunnelActive helper) so the edge gate
immediately stops treating the old subdomain as a live tunnel.
2026-06-02 22:57:13 +02:00

190 lines
5.7 KiB
TypeScript

import net from 'node:net';
/**
* Minimal, dependency-free Redis client implementing just enough of the RESP
* protocol to issue a single SET command. Used for the best-effort live
* kill-switch (tunnel:active:<subdomain>). Every failure mode is swallowed —
* Redis being unavailable must NEVER break an admin operation.
*
* REDIS_URL format: redis://[:password@]host:port[/db]
*/
type RedisTarget = {
host: string;
port: number;
password?: string;
db?: number;
};
function parseRedisUrl(raw: string): RedisTarget | null {
try {
const u = new URL(raw);
if (u.protocol !== 'redis:' && u.protocol !== 'rediss:') return null;
const host = u.hostname || '127.0.0.1';
const port = u.port ? Number(u.port) : 6379;
const password = u.password ? decodeURIComponent(u.password) : undefined;
const dbStr = u.pathname.replace(/^\//, '');
const db = dbStr ? Number(dbStr) : undefined;
if (!Number.isFinite(port)) return null;
return { host, port, password, db: Number.isFinite(db) ? db : undefined };
} catch {
return null;
}
}
function encodeCommand(args: string[]): string {
let out = `*${args.length}\r\n`;
for (const a of args) {
out += `$${Buffer.byteLength(a)}\r\n${a}\r\n`;
}
return out;
}
/**
* 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.
*/
function runCommand(args: string[]): Promise<boolean> {
const url = process.env.REDIS_URL;
if (!url) return Promise.resolve(false);
const target = parseRedisUrl(url);
if (!target) return Promise.resolve(false);
return new Promise<boolean>((resolve) => {
let settled = false;
const done = (result: boolean) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
/* ignore */
}
resolve(result);
};
const commands: string[] = [];
if (target.password) commands.push(encodeCommand(['AUTH', target.password]));
if (target.db !== undefined && target.db > 0) {
commands.push(encodeCommand(['SELECT', String(target.db)]));
}
commands.push(encodeCommand(args));
const socket = net.createConnection(
{ host: target.host, port: target.port },
() => {
try {
socket.write(commands.join(''));
} catch {
done(false);
}
},
);
socket.setTimeout(1500, () => done(false));
socket.on('error', () => done(false));
let buf = '';
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 / +... (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;
}
}
/**
* Clear the live kill-switch flag for a subdomain (DEL tunnel:active:<sub>).
* Used when a subdomain is renamed/released so its stale flag does not linger:
* with the key gone, the edge gate falls through to its Postgres lookup, finds
* no tunnel row, and returns a clean "tunnel not found" instead of the
* "suspended: quota exceeded or inactive" page. No-op (false) when REDIS_URL is
* unset. Never throws.
*/
export async function clearTunnelActive(subdomain: string): Promise<boolean> {
if (!process.env.REDIS_URL) return false;
if (!subdomain) return false;
try {
return await redisDel(`tunnel:active:${subdomain}`);
} catch {
return false;
}
}