From c4aae01209a4e3d88e6188454e2319e86b81285a Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Tue, 2 Jun 2026 22:57:13 +0200 Subject: [PATCH] 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: 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. --- app/api/device/tunnel/route.ts | 21 ++++++++++++++++++++- lib/redis.ts | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/api/device/tunnel/route.ts b/app/api/device/tunnel/route.ts index 1eaca06..6eead7d 100644 --- a/app/api/device/tunnel/route.ts +++ b/app/api/device/tunnel/route.ts @@ -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, diff --git a/lib/redis.ts b/lib/redis.ts index a1eb458..4aa1050 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -169,3 +169,21 @@ export async function setTunnelActive( return false; } } + +/** + * Clear the live kill-switch flag for a subdomain (DEL tunnel:active:). + * 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 { + if (!process.env.REDIS_URL) return false; + if (!subdomain) return false; + try { + return await redisDel(`tunnel:active:${subdomain}`); + } catch { + return false; + } +}