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.
This commit is contained in:
Gerhard Scheikl
2026-06-02 22:57:13 +02:00
parent 143fec7971
commit c4aae01209
2 changed files with 38 additions and 1 deletions
+20 -1
View File
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { jsonNoStore } from '@/lib/admin/response';
import { validateSubdomain } from '@/lib/validation';
import { setTunnelActive } from '@/lib/redis';
import { setTunnelActive, clearTunnelActive } from '@/lib/redis';
import { hashToken, FRP_SERVER_ADDR, FRP_SERVER_PORT } from '@/lib/device';
export const runtime = 'nodejs';
@@ -96,6 +96,16 @@ export async function POST(req: NextRequest) {
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
}
// Capture the user's CURRENT subdomain (if any) before the upsert overwrites
// it. On a rename we must clear the old subdomain's edge kill-switch key,
// otherwise it lingers as "active" until its ~30s TTL expires.
const { data: current } = await admin
.from('tunnels')
.select('subdomain')
.eq('user_id', device.user_id)
.maybeSingle<{ subdomain: string }>();
const previousSubdomain = current?.subdomain ?? null;
const token = randomBytes(32).toString('hex');
const { data, error } = await admin
.from('tunnels')
@@ -114,6 +124,15 @@ export async function POST(req: NextRequest) {
await setTunnelActive(subdomain, true);
// Rename: drop the old subdomain's kill-switch key so the edge gate stops
// treating it as a live tunnel. With the key gone the gate falls through to a
// Postgres lookup, finds no row, and returns a clean "tunnel not found"
// instead of leaving a stale "active" flag pointing at a subdomain that no
// longer exists.
if (previousSubdomain && previousSubdomain !== data.subdomain) {
await clearTunnelActive(previousSubdomain);
}
return jsonNoStore({
subdomain: data.subdomain,
token: data.token,