mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
feat(api-keys): add per-key device/connection tracking (#5998)
* feat(api-keys): add per-key device/connection tracking Tracks distinct client devices (SHA-256 fingerprint of IP + User-Agent) seen with each API key, with a 30-minute TTL and per-key/global caps. The tracker is in-memory only (module-scoped Map, same pattern as sessionManager.ts — no global.* singleton) and never stores the raw IP: it is masked before being written. Hooked into open-sse/handlers/chatCore.ts (the real chat entry) rather than the legacy src/sse/handlers path. New GET /api/keys/[id]/devices management route exposes masked device details for a key, and the API Keys dashboard tab gets a "Devices" count badge alongside the existing Sessions badge. This is a new granularity distinct from the existing maxSessions cap (src/lib/db/apiKeys.ts), which limits concurrent sticky-routing sessions rather than tracking device identity. Co-authored-by: Muhammad Mugni Hadi <mugni@rukita.co> Inspired-by: https://github.com/decolua/9router/pull/931 * chore(changelog): restore release entries + add api-keys device-tracking bullet --------- Co-authored-by: Muhammad Mugni Hadi <mugni@rukita.co>
This commit is contained in:
committed by
GitHub
parent
bff75cbd3c
commit
b6a11f04df
@@ -11,6 +11,7 @@
|
||||
- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic)
|
||||
- **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**.
|
||||
- **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix)
|
||||
- **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isStripReasoningRequested,
|
||||
} from "./chatCore/headers.ts";
|
||||
import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts";
|
||||
import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts";
|
||||
import { getCombosCached } from "./chatCore/comboContextCache.ts";
|
||||
export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts";
|
||||
import {
|
||||
@@ -455,6 +456,15 @@ export async function handleChatCore({
|
||||
if (pluginGate.body) {
|
||||
body = pluginGate.body;
|
||||
}
|
||||
// Per-API-key device/connection tracking (port of upstream 9router#931,
|
||||
// thanks @mugnimaestra). In-memory only, never blocks the request path.
|
||||
if (apiKeyInfo?.id) {
|
||||
trackDevice(
|
||||
apiKeyInfo.id,
|
||||
extractIpFromHeaders(clientRawRequest?.headers ?? null),
|
||||
userAgent ?? null
|
||||
);
|
||||
}
|
||||
const agentGoalPolicy = resolveAgentGoalPolicy(body, clientRawRequest?.headers ?? null);
|
||||
if (agentGoalPolicy.detected) {
|
||||
log?.debug?.(
|
||||
|
||||
307
open-sse/services/deviceTracker.ts
Normal file
307
open-sse/services/deviceTracker.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Per-API-Key Device Tracker
|
||||
*
|
||||
* Tracks unique client "devices" (IP + User-Agent fingerprint) that have used
|
||||
* a given API key, so operators can see how many distinct connections are
|
||||
* active behind a key — independent of `maxSessions` (which caps concurrent
|
||||
* sticky-routing sessions, not device identity; see `sessionManager.ts`).
|
||||
*
|
||||
* In-memory only, module-scoped Map (same pattern as `sessionManager.ts` —
|
||||
* no `global.*` singleton). Records never store the raw IP: it is masked
|
||||
* before being written, so even a memory dump or the `/api/keys/[id]/devices`
|
||||
* endpoint can't leak a full client IP.
|
||||
*
|
||||
* Ported from upstream 9router#931 (thanks @mugnimaestra) — original stored
|
||||
* a global singleton keyed by the raw API key string; this port keys by
|
||||
* `apiKeyInfo.id` (OmniRoute never threads the raw key value down to
|
||||
* `chatCore`) and follows the module-Map + `unref()` cleanup-timer pattern
|
||||
* used across `open-sse/services/`.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_MAX_DEVICES_PER_API_KEY = 1000;
|
||||
const DEFAULT_MAX_TOTAL_DEVICES = 10000;
|
||||
const MAX_STORED_USER_AGENT_LENGTH = 256;
|
||||
|
||||
const TTL_ENV_NAME = "DEVICE_TRACKER_TTL_MS";
|
||||
const MAX_PER_KEY_ENV_NAME = "DEVICE_TRACKER_MAX_DEVICES_PER_KEY";
|
||||
const MAX_TOTAL_ENV_NAME = "DEVICE_TRACKER_MAX_TOTAL_DEVICES";
|
||||
|
||||
interface DeviceRecord {
|
||||
fingerprint: string;
|
||||
/** Already masked — never the raw client IP. */
|
||||
ip: string;
|
||||
/** Truncated to MAX_STORED_USER_AGENT_LENGTH. */
|
||||
userAgent: string;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface DeviceDetail {
|
||||
/** Truncated fingerprint (first 12 hex chars) — never the full hash. */
|
||||
fingerprint: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
function parseTtlMs(): number {
|
||||
const rawValue = process.env[TTL_ENV_NAME];
|
||||
if (!rawValue) return DEFAULT_TTL_MS;
|
||||
const parsedValue = Number(rawValue);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue <= 0) return DEFAULT_TTL_MS;
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerEnv(envName: string, defaultValue: number): number {
|
||||
const rawValue = process.env[envName];
|
||||
if (!rawValue) return defaultValue;
|
||||
const parsedValue = Number(rawValue);
|
||||
if (!Number.isInteger(parsedValue) || parsedValue <= 0) return defaultValue;
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
let ttlMs = parseTtlMs();
|
||||
let maxDevicesPerApiKey = parsePositiveIntegerEnv(
|
||||
MAX_PER_KEY_ENV_NAME,
|
||||
DEFAULT_MAX_DEVICES_PER_API_KEY
|
||||
);
|
||||
let maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES);
|
||||
|
||||
// Module-scoped in-memory store — mirrors the `sessionManager.ts` pattern.
|
||||
// key: apiKeyId → Map<fingerprint, DeviceRecord>
|
||||
const devicesByApiKey = new Map<string, Map<string, DeviceRecord>>();
|
||||
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Mask an IP address so the stored/reported value never reveals the full
|
||||
* client address. IPv4 keeps the first two octets; IPv6 keeps the first
|
||||
* three groups.
|
||||
*/
|
||||
export function maskIp(ip: string | null | undefined): string {
|
||||
if (!ip || ip === "unknown") return "unknown";
|
||||
|
||||
const ipv4Parts = ip.split(".");
|
||||
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d{1,3}$/.test(part))) {
|
||||
return `${ipv4Parts[0]}.${ipv4Parts[1]}.x.x`;
|
||||
}
|
||||
|
||||
if (ip.includes(":")) {
|
||||
const visibleGroups = ip.split(":").filter(Boolean).slice(0, 3).join(":");
|
||||
return visibleGroups ? `${visibleGroups}:...` : "unknown";
|
||||
}
|
||||
|
||||
return "masked";
|
||||
}
|
||||
|
||||
function truncateUserAgent(userAgent: string): string {
|
||||
if (userAgent.length <= MAX_STORED_USER_AGENT_LENGTH) return userAgent;
|
||||
return `${userAgent.slice(0, MAX_STORED_USER_AGENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
function createFingerprint(ip: string, userAgent: string): string {
|
||||
return createHash("sha256").update(`${ip}|${userAgent}`).digest("hex");
|
||||
}
|
||||
|
||||
function getTotalDeviceCount(): number {
|
||||
let count = 0;
|
||||
for (const devices of devicesByApiKey.values()) count += devices.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
function deleteDevice(apiKeyId: string, fingerprint: string): boolean {
|
||||
const devices = devicesByApiKey.get(apiKeyId);
|
||||
if (!devices) return false;
|
||||
const deleted = devices.delete(fingerprint);
|
||||
if (devices.size === 0) devicesByApiKey.delete(apiKeyId);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function findOldestDevice(
|
||||
apiKeyId: string | null
|
||||
): { apiKeyId: string; fingerprint: string; lastSeen: number } | null {
|
||||
let oldest: { apiKeyId: string; fingerprint: string; lastSeen: number } | null = null;
|
||||
const entries = apiKeyId ? [[apiKeyId, devicesByApiKey.get(apiKeyId)] as const] : devicesByApiKey.entries();
|
||||
|
||||
for (const [entryApiKeyId, devices] of entries) {
|
||||
if (!devices) continue;
|
||||
for (const [fingerprint, record] of devices.entries()) {
|
||||
if (!oldest || record.lastSeen < oldest.lastSeen) {
|
||||
oldest = { apiKeyId: entryApiKeyId, fingerprint, lastSeen: record.lastSeen };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return oldest;
|
||||
}
|
||||
|
||||
function evictOldestDevice(apiKeyId: string | null = null): boolean {
|
||||
const oldest = findOldestDevice(apiKeyId);
|
||||
if (!oldest) return false;
|
||||
return deleteDevice(oldest.apiKeyId, oldest.fingerprint);
|
||||
}
|
||||
|
||||
function enforceDeviceLimits(apiKeyId: string, devices: Map<string, DeviceRecord>): void {
|
||||
while (devices.size >= maxDevicesPerApiKey) {
|
||||
if (!evictOldestDevice(apiKeyId)) break;
|
||||
}
|
||||
while (getTotalDeviceCount() >= maxTotalDevices) {
|
||||
if (!evictOldestDevice()) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired device records. Exported for tests; the cleanup timer
|
||||
* calls this on an interval in production.
|
||||
*/
|
||||
export function expireDevices(now: number = Date.now()): number {
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [apiKeyId, devices] of devicesByApiKey.entries()) {
|
||||
for (const [fingerprint, record] of devices.entries()) {
|
||||
if (now - record.lastSeen > ttlMs) {
|
||||
devices.delete(fingerprint);
|
||||
expiredCount += 1;
|
||||
}
|
||||
}
|
||||
if (devices.size === 0) devicesByApiKey.delete(apiKeyId);
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
|
||||
function ensureCleanupTimer(): void {
|
||||
if (cleanupTimer) return;
|
||||
cleanupTimer = setInterval(() => {
|
||||
expireDevices();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
ensureCleanupTimer();
|
||||
|
||||
/**
|
||||
* Extract the client IP from a header source. Mirrors the priority order
|
||||
* already used across `open-sse/` (`cf-connecting-ip` → `x-real-ip` →
|
||||
* `x-forwarded-for`, first hop only). Returns "unknown" when absent.
|
||||
*/
|
||||
export function extractIpFromHeaders(
|
||||
headers: Record<string, unknown> | Headers | null | undefined
|
||||
): string {
|
||||
if (!headers) return "unknown";
|
||||
|
||||
const getHeader = (name: string): string | null => {
|
||||
if (headers instanceof Headers) return headers.get(name);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === name && typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const edgeIp =
|
||||
getHeader("cf-connecting-ip") || getHeader("x-real-ip") || getHeader("fastly-client-ip");
|
||||
if (edgeIp) return edgeIp;
|
||||
|
||||
const forwardedFor = getHeader("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
const firstIp = forwardedFor.split(",")[0]?.trim();
|
||||
if (firstIp) return firstIp;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a device (IP + User-Agent fingerprint) for an API key. Idempotent —
|
||||
* calling it again for the same key + fingerprint just refreshes `lastSeen`.
|
||||
* No-ops (returns null) when `apiKeyId` is missing, so callers can call it
|
||||
* unconditionally after resolving `apiKeyInfo`.
|
||||
*/
|
||||
export function trackDevice(
|
||||
apiKeyId: string | null | undefined,
|
||||
ip: string | null | undefined,
|
||||
userAgent: string | null | undefined
|
||||
): string | null {
|
||||
if (!apiKeyId || typeof apiKeyId !== "string") return null;
|
||||
|
||||
const now = Date.now();
|
||||
expireDevices(now);
|
||||
|
||||
const resolvedIp = ip && ip.trim() ? ip.trim() : "unknown";
|
||||
const resolvedUserAgent = userAgent && userAgent.trim() ? userAgent.trim() : "unknown";
|
||||
const fingerprint = createFingerprint(resolvedIp, resolvedUserAgent);
|
||||
|
||||
let devices = devicesByApiKey.get(apiKeyId);
|
||||
if (!devices) {
|
||||
devices = new Map();
|
||||
devicesByApiKey.set(apiKeyId, devices);
|
||||
}
|
||||
|
||||
const existingRecord = devices.get(fingerprint);
|
||||
if (existingRecord) {
|
||||
existingRecord.lastSeen = now;
|
||||
} else {
|
||||
enforceDeviceLimits(apiKeyId, devices);
|
||||
if (!devicesByApiKey.has(apiKeyId)) devicesByApiKey.set(apiKeyId, devices);
|
||||
devices.set(fingerprint, {
|
||||
fingerprint,
|
||||
ip: maskIp(resolvedIp),
|
||||
userAgent: truncateUserAgent(resolvedUserAgent),
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/** Number of distinct devices currently tracked for an API key. */
|
||||
export function getDeviceCount(apiKeyId: string | null | undefined): number {
|
||||
expireDevices();
|
||||
if (!apiKeyId || typeof apiKeyId !== "string") return 0;
|
||||
return devicesByApiKey.get(apiKeyId)?.size || 0;
|
||||
}
|
||||
|
||||
/** Device detail rows for an API key — masked IP, truncated fingerprint. */
|
||||
export function getDeviceDetails(apiKeyId: string | null | undefined): DeviceDetail[] {
|
||||
expireDevices();
|
||||
if (!apiKeyId || typeof apiKeyId !== "string") return [];
|
||||
|
||||
const devices = devicesByApiKey.get(apiKeyId);
|
||||
if (!devices) return [];
|
||||
|
||||
return Array.from(devices.values()).map((record) => ({
|
||||
fingerprint: record.fingerprint.slice(0, 12),
|
||||
ip: record.ip,
|
||||
userAgent: record.userAgent,
|
||||
lastSeen: record.lastSeen,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Device counts for every tracked API key. */
|
||||
export function getAllDeviceCounts(): Record<string, number> {
|
||||
expireDevices();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [apiKeyId, devices] of devicesByApiKey.entries()) {
|
||||
counts[apiKeyId] = devices.size;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only reset — mirrors `sessionManager.ts::clearSessions()`. Also lets
|
||||
* tests override the TTL/limit env vars deterministically.
|
||||
*/
|
||||
export function clearDeviceTracker(): void {
|
||||
devicesByApiKey.clear();
|
||||
ttlMs = parseTtlMs();
|
||||
maxDevicesPerApiKey = parsePositiveIntegerEnv(
|
||||
MAX_PER_KEY_ENV_NAME,
|
||||
DEFAULT_MAX_DEVICES_PER_API_KEY
|
||||
);
|
||||
maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES);
|
||||
}
|
||||
@@ -222,6 +222,7 @@ export default function ApiManagerPageClient() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
|
||||
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
|
||||
const [deviceCounts, setDeviceCounts] = useState<Record<string, number>>({});
|
||||
const [allowKeyReveal, setAllowKeyReveal] = useState(false);
|
||||
// Per-row API key visibility toggle (eye / eye-off). Keys default to masked.
|
||||
// Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal.
|
||||
@@ -367,6 +368,7 @@ export default function ApiManagerPageClient() {
|
||||
// Fetch usage stats after keys are loaded
|
||||
fetchUsageStats(data.keys || []);
|
||||
fetchSessionCounts(data.keys || []);
|
||||
fetchDeviceCounts(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
@@ -447,6 +449,35 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// Per-key device/connection counts (port of upstream 9router#931, thanks
|
||||
// @mugnimaestra). One lightweight GET per key against
|
||||
// /api/keys/[id]/devices — device counts are in-memory + TTL-evicted, so
|
||||
// this is a much smaller payload than session data.
|
||||
const fetchDeviceCounts = async (apiKeys: ApiKey[]) => {
|
||||
if (apiKeys.length === 0) {
|
||||
setDeviceCounts({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
apiKeys.map(async (key) => {
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(key.id)}/devices`);
|
||||
if (!res.ok) return [key.id, 0] as const;
|
||||
const data = await res.json();
|
||||
const count = typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0;
|
||||
return [key.id, count] as const;
|
||||
} catch {
|
||||
return [key.id, 0] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
setDeviceCounts(Object.fromEntries(results));
|
||||
} catch (error) {
|
||||
console.log("Error fetching device counts:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPageError = useCallback(() => setPageError(null), []);
|
||||
|
||||
const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]);
|
||||
@@ -963,6 +994,7 @@ export default function ApiManagerPageClient() {
|
||||
const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0;
|
||||
const hasSessionLimit = maxSessions > 0;
|
||||
const activeSessions = sessionCounts[key.id] || 0;
|
||||
const deviceCount = deviceCounts[key.id] || 0;
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
const keyIsQuota = isQuotaKey(key);
|
||||
const groups = quotaGroupsForKey(key);
|
||||
@@ -1124,6 +1156,15 @@ export default function ApiManagerPageClient() {
|
||||
Sessions: {activeSessions}/{maxSessions}
|
||||
</span>
|
||||
)}
|
||||
{deviceCount > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 text-[11px] font-medium"
|
||||
title={t("devicesTooltip", { count: deviceCount })}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">devices</span>
|
||||
{t("devicesCount", { count: deviceCount })}
|
||||
</span>
|
||||
)}
|
||||
{hasThrottle && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">speed</span>+
|
||||
|
||||
46
src/app/api/keys/[id]/devices/route.ts
Normal file
46
src/app/api/keys/[id]/devices/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Per-API-Key Device List — Read Route
|
||||
*
|
||||
* Lists the distinct devices (IP + User-Agent fingerprints) tracked for an
|
||||
* API key by `open-sse/services/deviceTracker.ts` (in-memory, TTL-evicted).
|
||||
* IPs are already masked by the tracker before storage — this route never
|
||||
* has access to the raw client IP or the full SHA-256 fingerprint.
|
||||
*
|
||||
* Ported from upstream 9router#931 (thanks @mugnimaestra) — the original
|
||||
* exposed a flat `GET /api/keys/devices` listing every key; this route
|
||||
* follows the OmniRoute `[id]` sub-resource convention (see
|
||||
* `src/app/api/keys/[id]/usage-limits/route.ts`) and requires management
|
||||
* auth like every other `/api/keys/[id]/*` route.
|
||||
*
|
||||
* @route /api/keys/[id]/devices
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getApiKeyById } from "@/lib/db/apiKeys";
|
||||
import { getDeviceCount, getDeviceDetails } from "@omniroute/open-sse/services/deviceTracker.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const key = await getApiKeyById(id);
|
||||
if (!key || typeof key.id !== "string") {
|
||||
return NextResponse.json(buildErrorBody(404, "Key not found"), { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
keyId: key.id,
|
||||
name: typeof key.name === "string" ? key.name : "",
|
||||
count: getDeviceCount(key.id),
|
||||
devices: getDeviceDetails(key.id),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("keys", "Error fetching API key devices", error);
|
||||
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1763,6 +1763,8 @@
|
||||
"copyMaskedKey": "Copy masked key",
|
||||
"keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key",
|
||||
"modelsCount": "{count, plural, one {# model} other {# models}}",
|
||||
"devicesCount": "{count, plural, one {# device} other {# devices}}",
|
||||
"devicesTooltip": "{count, plural, one {# distinct IP/User-Agent device seen with this key (last 30 min)} other {# distinct IP/User-Agent devices seen with this key (last 30 min)}}",
|
||||
"lastUsedOn": "Last: {date}",
|
||||
"viewCostsFor": "View costs for {name}",
|
||||
"editPermissions": "Edit permissions",
|
||||
|
||||
195
tests/unit/device-tracker.test.ts
Normal file
195
tests/unit/device-tracker.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
trackDevice,
|
||||
getDeviceCount,
|
||||
getDeviceDetails,
|
||||
getAllDeviceCounts,
|
||||
expireDevices,
|
||||
extractIpFromHeaders,
|
||||
maskIp,
|
||||
clearDeviceTracker,
|
||||
} = await import("../../open-sse/services/deviceTracker.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
delete process.env.DEVICE_TRACKER_TTL_MS;
|
||||
delete process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY;
|
||||
delete process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES;
|
||||
clearDeviceTracker();
|
||||
});
|
||||
|
||||
// ─── Fingerprint dedup ──────────────────────────────────────────────────────
|
||||
|
||||
test("trackDevice: same IP + UA for a key counts as one device", async () => {
|
||||
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
||||
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
||||
await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent");
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 1);
|
||||
});
|
||||
|
||||
test("trackDevice: different User-Agent for same key/IP counts as a new device", async () => {
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
await trackDevice("key-1", "203.0.113.5", "python-requests/2.31");
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 2);
|
||||
});
|
||||
|
||||
test("trackDevice: different IP for same key/UA counts as a new device", async () => {
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
await trackDevice("key-1", "198.51.100.9", "curl/8.0");
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 2);
|
||||
});
|
||||
|
||||
test("trackDevice: repeated tracking refreshes lastSeen instead of duplicating", async () => {
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
const [before] = getDeviceDetails("key-1");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
const [after] = getDeviceDetails("key-1");
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 1);
|
||||
assert.ok(after.lastSeen >= before.lastSeen);
|
||||
});
|
||||
|
||||
test("trackDevice: no-ops and returns null when apiKeyId is missing", async () => {
|
||||
const fingerprint = await trackDevice(null, "203.0.113.5", "curl/8.0");
|
||||
assert.equal(fingerprint, null);
|
||||
assert.equal(getDeviceCount(null), 0);
|
||||
});
|
||||
|
||||
// ─── Per-key isolation ──────────────────────────────────────────────────────
|
||||
|
||||
test("trackDevice: devices are scoped per API key, not global", async () => {
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
await trackDevice("key-2", "203.0.113.5", "curl/8.0"); // same fingerprint, different key
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 1);
|
||||
assert.equal(getDeviceCount("key-2"), 1);
|
||||
|
||||
const allCounts = getAllDeviceCounts();
|
||||
assert.equal(allCounts["key-1"], 1);
|
||||
assert.equal(allCounts["key-2"], 1);
|
||||
});
|
||||
|
||||
// ─── TTL expiry ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("expireDevices: evicts devices whose lastSeen exceeds the TTL window", async () => {
|
||||
process.env.DEVICE_TRACKER_TTL_MS = "1000";
|
||||
clearDeviceTracker();
|
||||
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
assert.equal(getDeviceCount("key-1"), 1);
|
||||
|
||||
const farFuture = Date.now() + 5000;
|
||||
const expiredCount = expireDevices(farFuture);
|
||||
|
||||
assert.equal(expiredCount, 1);
|
||||
assert.equal(getDeviceCount("key-1"), 0);
|
||||
});
|
||||
|
||||
test("expireDevices: keeps devices seen within the TTL window", async () => {
|
||||
process.env.DEVICE_TRACKER_TTL_MS = "60000";
|
||||
clearDeviceTracker();
|
||||
|
||||
await trackDevice("key-1", "203.0.113.5", "curl/8.0");
|
||||
const soon = Date.now() + 1000;
|
||||
const expiredCount = expireDevices(soon);
|
||||
|
||||
assert.equal(expiredCount, 0);
|
||||
assert.equal(getDeviceCount("key-1"), 1);
|
||||
});
|
||||
|
||||
// ─── Eviction under caps ────────────────────────────────────────────────────
|
||||
|
||||
test("trackDevice: enforces maxDevicesPerApiKey by evicting the oldest device", async () => {
|
||||
process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY = "2";
|
||||
clearDeviceTracker();
|
||||
|
||||
await trackDevice("key-1", "203.0.113.1", "ua-1");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await trackDevice("key-1", "203.0.113.2", "ua-2");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
// Third distinct device should evict the oldest (203.0.113.1 / ua-1).
|
||||
await trackDevice("key-1", "203.0.113.3", "ua-3");
|
||||
|
||||
assert.equal(getDeviceCount("key-1"), 2);
|
||||
const uaSet = new Set(getDeviceDetails("key-1").map((d) => d.userAgent));
|
||||
assert.ok(!uaSet.has("ua-1"), "oldest device should have been evicted");
|
||||
assert.ok(uaSet.has("ua-2"));
|
||||
assert.ok(uaSet.has("ua-3"));
|
||||
});
|
||||
|
||||
test("trackDevice: enforces maxTotalDevices globally across all keys", async () => {
|
||||
process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES = "2";
|
||||
clearDeviceTracker();
|
||||
|
||||
await trackDevice("key-1", "203.0.113.1", "ua-1");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await trackDevice("key-2", "203.0.113.2", "ua-2");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await trackDevice("key-3", "203.0.113.3", "ua-3");
|
||||
|
||||
const total = Object.values(getAllDeviceCounts()).reduce((a, b) => a + b, 0);
|
||||
assert.equal(total, 2);
|
||||
// key-1's device was the oldest globally and should be gone.
|
||||
assert.equal(getDeviceCount("key-1"), 0);
|
||||
});
|
||||
|
||||
// ─── IP masking ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("maskIp: masks the last two octets of an IPv4 address", () => {
|
||||
assert.equal(maskIp("203.0.113.42"), "203.0.x.x");
|
||||
});
|
||||
|
||||
test("maskIp: masks an IPv6 address down to its first three groups", () => {
|
||||
assert.equal(maskIp("2001:db8:85a3:0:0:8a2e:370:7334"), "2001:db8:85a3:...");
|
||||
});
|
||||
|
||||
test("maskIp: returns 'unknown' for missing/unknown input", () => {
|
||||
assert.equal(maskIp(null), "unknown");
|
||||
assert.equal(maskIp(undefined), "unknown");
|
||||
assert.equal(maskIp("unknown"), "unknown");
|
||||
});
|
||||
|
||||
test("trackDevice: never stores the raw IP — only the masked form", async () => {
|
||||
await trackDevice("key-1", "203.0.113.42", "curl/8.0");
|
||||
const [detail] = getDeviceDetails("key-1");
|
||||
|
||||
assert.equal(detail.ip, "203.0.x.x");
|
||||
assert.notEqual(detail.ip, "203.0.113.42");
|
||||
});
|
||||
|
||||
test("getDeviceDetails: truncates the fingerprint instead of exposing the full SHA-256 hash", async () => {
|
||||
await trackDevice("key-1", "203.0.113.42", "curl/8.0");
|
||||
const [detail] = getDeviceDetails("key-1");
|
||||
|
||||
assert.equal(detail.fingerprint.length, 12);
|
||||
});
|
||||
|
||||
// ─── IP extraction from headers ─────────────────────────────────────────────
|
||||
|
||||
test("extractIpFromHeaders: prefers cf-connecting-ip over x-forwarded-for", () => {
|
||||
const ip = extractIpFromHeaders({
|
||||
"cf-connecting-ip": "203.0.113.5",
|
||||
"x-forwarded-for": "198.51.100.9, 10.0.0.1",
|
||||
});
|
||||
assert.equal(ip, "203.0.113.5");
|
||||
});
|
||||
|
||||
test("extractIpFromHeaders: falls back to the first hop of x-forwarded-for", () => {
|
||||
const ip = extractIpFromHeaders({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" });
|
||||
assert.equal(ip, "198.51.100.9");
|
||||
});
|
||||
|
||||
test("extractIpFromHeaders: works with a real Headers instance", () => {
|
||||
const headers = new Headers({ "x-real-ip": "192.0.2.7" });
|
||||
assert.equal(extractIpFromHeaders(headers), "192.0.2.7");
|
||||
});
|
||||
|
||||
test("extractIpFromHeaders: returns 'unknown' when no IP header is present", () => {
|
||||
assert.equal(extractIpFromHeaders({}), "unknown");
|
||||
assert.equal(extractIpFromHeaders(null), "unknown");
|
||||
});
|
||||
Reference in New Issue
Block a user