Files
linumiq_net-web_app/app/api/device/tunnel/route.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

158 lines
5.2 KiB
TypeScript

import { type NextRequest } from 'next/server';
import { randomBytes } from 'node:crypto';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { jsonNoStore } from '@/lib/admin/response';
import { validateSubdomain } from '@/lib/validation';
import { setTunnelActive, clearTunnelActive } from '@/lib/redis';
import { hashToken, FRP_SERVER_ADDR, FRP_SERVER_PORT } from '@/lib/device';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type DeviceTokenRow = { id: number; user_id: string };
/**
* Authenticate a request by its `Authorization: Bearer <device-token>` header.
* Returns the matching, non-revoked device_tokens row (and bumps last_used_at),
* or null when the token is missing/invalid/revoked.
*/
async function authDevice(req: NextRequest): Promise<DeviceTokenRow | null> {
const auth = req.headers.get('authorization') ?? '';
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
if (!m) return null;
const admin = getSupabaseAdmin();
const { data, error } = await admin
.from('device_tokens')
.select('id, user_id')
.eq('token_hash', hashToken(m[1]))
.is('revoked_at', null)
.maybeSingle<DeviceTokenRow>();
if (error || !data) return null;
await admin
.from('device_tokens')
.update({ last_used_at: new Date().toISOString() })
.eq('id', data.id);
return data;
}
type Tunnel = { subdomain: string; token: string };
/** GET — return the device's current tunnel config. */
export async function GET(req: NextRequest) {
const device = await authDevice(req);
if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 });
const admin = getSupabaseAdmin();
const { data: tunnel, error } = await admin
.from('tunnels')
.select('subdomain, token')
.eq('user_id', device.user_id)
.maybeSingle<Tunnel>();
if (error) return jsonNoStore({ error: 'failed' }, { status: 500 });
if (!tunnel) return jsonNoStore({ error: 'no_tunnel' }, { status: 404 });
return jsonNoStore({
subdomain: tunnel.subdomain,
token: tunnel.token,
server_addr: FRP_SERVER_ADDR,
server_port: FRP_SERVER_PORT,
});
}
type PostBody = { subdomain?: unknown };
/** POST — claim/change the subdomain (rotates the frp token). */
export async function POST(req: NextRequest) {
const device = await authDevice(req);
if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 });
let body: PostBody;
try {
body = (await req.json()) as PostBody;
} catch {
return jsonNoStore({ error: 'invalid json' }, { status: 400 });
}
const v = validateSubdomain(body.subdomain);
if (!v.ok) {
return jsonNoStore({ error: v.error }, { status: 400 });
}
const subdomain = v.value;
const admin = getSupabaseAdmin();
const { data: existing, error: existingErr } = await admin
.from('tunnels')
.select('user_id')
.eq('subdomain', subdomain)
.maybeSingle<{ user_id: string }>();
if (existingErr) {
return jsonNoStore({ error: 'failed' }, { status: 500 });
}
if (existing && existing.user_id !== device.user_id) {
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')
.upsert(
{ user_id: device.user_id, subdomain, token, is_active: true },
{ onConflict: 'user_id' },
)
.select('subdomain, token')
.single<Tunnel>();
if (error) {
if ((error as { code?: string }).code === '23505') {
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
}
return jsonNoStore({ error: 'failed' }, { status: 500 });
}
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,
server_addr: FRP_SERVER_ADDR,
server_port: FRP_SERVER_PORT,
});
}
/** DELETE — unpair this device by revoking its token (tunnel is kept). */
export async function DELETE(req: NextRequest) {
const device = await authDevice(req);
if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 });
const admin = getSupabaseAdmin();
const { error } = await admin
.from('device_tokens')
.update({ revoked_at: new Date().toISOString() })
.eq('id', device.id);
if (error) return jsonNoStore({ error: 'failed' }, { status: 500 });
return jsonNoStore({ ok: true });
}