Merge pull request #3641 from fosrl/dev

1.22.0
This commit is contained in:
Owen Schwartz
2026-08-25 17:19:46 -04:00
committed by GitHub
101 changed files with 2209 additions and 886 deletions
+3
View File
@@ -468,6 +468,9 @@ export const eventStreamingDestinations = pgTable(
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
sendAISessionLogs: boolean("sendAISessionLogs")
.notNull()
.default(false),
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: boolean("enabled").notNull().default(true),
+3
View File
@@ -459,6 +459,9 @@ export const eventStreamingDestinations = sqliteTable(
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
.notNull()
.default(false),
sendAISessionLogs: integer("sendAISessionLogs", { mode: "boolean" })
.notNull()
.default(false),
type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" })
+16 -1
View File
@@ -4,7 +4,7 @@ import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export { AI_CAPABILITIES, type AiCapability };
export type AiCapabilityRoute = {
method: "POST";
method: "GET" | "POST";
path: string;
};
@@ -135,6 +135,21 @@ export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
v1_models: {
id: "v1_models",
protocolFamily: "anthropic",
routes: [
{ method: "GET", path: "/v1/models" },
{ method: "GET", path: "/v1/models/:model" }
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
// Model listings are answered from the gateway's own view of the
// provider allow/block lists rather than proxied upstream, so
// there is never a stream to detect.
isStreaming: () => false
},
gemini_generate_content: {
id: "gemini_generate_content",
protocolFamily: "google",
+3
View File
@@ -471,6 +471,8 @@ const REQUEST_NORMALIZERS: Record<
openai_chat: normalizeOpenAiChatRequest,
openai_responses: normalizeOpenAiResponsesRequest,
anthropic_messages: normalizeAnthropicRequest,
// Model discovery carries no transcript to normalize.
v1_models: () => null,
gemini_generate_content: normalizeGeminiRequest,
google_generate_content: normalizeGeminiRequest,
google_raw_predict: normalizeBestEffortRequest,
@@ -485,6 +487,7 @@ const RESPONSE_NORMALIZERS: Record<
openai_chat: normalizeOpenAiChatResponse,
openai_responses: normalizeOpenAiResponsesResponse,
anthropic_messages: normalizeAnthropicResponse,
v1_models: () => null,
gemini_generate_content: normalizeGeminiResponse,
google_generate_content: normalizeGeminiResponse,
google_raw_predict: normalizeGoogleRawPredictResponse,
+88 -24
View File
@@ -44,6 +44,20 @@ export function getCatalogProviderForType(
return PROVIDER_CATALOG_MAP[type];
}
/**
* Per-model feature flags as reported upstream. `null` means the catalog has
* no data for that model - deliberately distinct from `false`, so consumers
* can tell "unsupported" apart from "unknown".
*/
export type AiModelCapabilityFlags = {
functionCalling: boolean | null;
vision: boolean | null;
promptCaching: boolean | null;
reasoning: boolean | null;
responseSchema: boolean | null;
webSearch: boolean | null;
};
export type AiModelCatalogEntry = {
provider: CatalogProvider;
model: string;
@@ -53,8 +67,20 @@ export type AiModelCatalogEntry = {
cache: number | null;
reasoning: number | null;
};
limits: {
/** Context window. */
input: number | null;
/** Cap on the output/max_tokens request parameter. */
output: number | null;
};
capabilities: AiModelCapabilityFlags;
};
const flag = z.boolean().nullable().optional();
// limits/capabilities are optional so a catalog published before they were
// added (or an operator's own merge_file) still parses - those entries just
// report unknown metadata rather than failing the whole payload.
const catalogEntrySchema = z.object({
model: z.string(),
provider: z.string(),
@@ -65,6 +91,22 @@ const catalogEntrySchema = z.object({
cache: z.number().nullable().optional(),
reasoning: z.number().nullable().optional()
})
.optional(),
limits: z
.object({
input: z.number().nullable().optional(),
output: z.number().nullable().optional()
})
.optional(),
capabilities: z
.object({
functionCalling: flag,
vision: flag,
promptCaching: flag,
reasoning: flag,
responseSchema: flag,
webSearch: flag
})
.optional()
});
@@ -108,6 +150,18 @@ function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
out: raw.pricing?.out ?? null,
cache: raw.pricing?.cache ?? null,
reasoning: raw.pricing?.reasoning ?? null
},
limits: {
input: raw.limits?.input ?? null,
output: raw.limits?.output ?? null
},
capabilities: {
functionCalling: raw.capabilities?.functionCalling ?? null,
vision: raw.capabilities?.vision ?? null,
promptCaching: raw.capabilities?.promptCaching ?? null,
reasoning: raw.capabilities?.reasoning ?? null,
responseSchema: raw.capabilities?.responseSchema ?? null,
webSearch: raw.capabilities?.webSearch ?? null
}
};
}
@@ -284,34 +338,44 @@ export class AiModelCatalog {
export const aiModelCatalog = new AiModelCatalog();
/**
* Full catalog entries for a provider type, deduplicated by model id and
* sorted by id. Model discovery uses these to report real token limits and
* capability flags; `listCatalogModelsForType` is the id-only view of the
* same list.
*/
export function listCatalogEntriesForType(
type: AiProviderType,
query?: string
): AiModelCatalogEntry[] {
const catalogProvider = getCatalogProviderForType(type);
let entries = catalogProvider ? aiModelCatalog.list(catalogProvider) : [];
if (query) {
const q = query.toLowerCase();
entries = entries.filter((e) => e.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
entries = entries.filter((e) => {
if (seen.has(e.model)) {
return false;
}
seen.add(e.model);
return true;
});
return [...entries].sort((a, b) => a.model.localeCompare(b.model));
}
export function listCatalogModelsForType(
type: AiProviderType,
query?: string
): { model: string }[] {
const catalogProvider = getCatalogProviderForType(type);
let models = catalogProvider
? aiModelCatalog.list(catalogProvider).map((entry) => ({
model: entry.model
}))
: [];
if (query) {
const q = query.toLowerCase();
models = models.filter((m) => m.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
models = models.filter((m) => {
if (seen.has(m.model)) {
return false;
}
seen.add(m.model);
return true;
});
models.sort((a, b) => a.model.localeCompare(b.model));
return models;
return listCatalogEntriesForType(type, query).map((entry) => ({
model: entry.model
}));
}
/**
+235
View File
@@ -0,0 +1,235 @@
import {
isAllowedByLists,
isModelKeyPattern
} from "@server/lib/aiModelKeyMatch";
import type { AiModelCapabilityFlags } from "@server/lib/aiModelCatalog";
// Anthropic's Models API pagination: 20 per page by default, 1..1000.
export const MODEL_PAGE_DEFAULT_LIMIT = 20;
export const MODEL_PAGE_MAX_LIMIT = 1000;
// Release dates aren't something we can know for a wildcard allow pattern or a
// catalog entry. The Models API explicitly permits an epoch value when the
// release date is unknown.
const UNKNOWN_CREATED_AT = new Date(0).toISOString();
/**
* One entry of Anthropic's `GET /v1/models` response. Only the identity fields
* can be filled in from a provider's model lists - token limits and
* per-model capability flags aren't derivable from an allow/block list, and the
* API schema declares all three nullable.
*/
export type AnthropicModelInfo = {
type: "model";
id: string;
display_name: string;
created_at: string;
max_input_tokens: number | null;
max_tokens: number | null;
capabilities: Record<string, unknown> | null;
};
/** A model row an administrator configured explicitly on a provider. */
export type ConfiguredModel = { name: string; createdAt: number };
/** What the pricing catalog knows about a model beyond its id. */
export type CatalogModelMetadata = {
maxInputTokens: number | null;
maxOutputTokens: number | null;
capabilities: AiModelCapabilityFlags;
};
/**
* Translates the catalog's flat feature flags into the nested shape
* Anthropic's Models API uses. Best-effort by nature: the catalog carries a
* coarser set of flags than the Models API describes, so anything it reports
* as unknown (`null`) is surfaced as unsupported rather than invented.
*/
export function capabilitiesFromCatalog(
flags: AiModelCapabilityFlags
): Record<string, unknown> {
const supported = (value: boolean | null) => ({
supported: value === true
});
// The catalog has a single `reasoning` flag and no way to distinguish
// adaptive from budget_tokens-style thinking, so both variants follow it.
const reasoning = flags.reasoning === true;
return {
batch: supported(null),
citations: supported(null),
code_execution: supported(null),
context_management: {
supported: false,
clear_thinking_20251015: null,
clear_tool_uses_20250919: null,
compact_20260112: null
},
effort: {
supported: reasoning,
low: supported(flags.reasoning),
medium: supported(flags.reasoning),
high: supported(flags.reasoning),
max: supported(flags.reasoning),
xhigh: null
},
image_input: supported(flags.vision),
pdf_input: supported(null),
structured_outputs: supported(flags.responseSchema),
thinking: {
supported: reasoning,
types: {
adaptive: { supported: reasoning },
enabled: { supported: reasoning }
}
}
};
}
/**
* One attached provider's contribution to a resource's model listing, with the
* allow/block lists already resolved for the attachment's access mode.
*/
export type ModelDiscoveryProvider = {
providerId: number;
allows: string[];
blocks: string[];
/**
* Concrete model ids the provider's type is known to serve, with whatever
* the catalog knows about each. This is what lets a wildcard allow such as
* `claude-*` enumerate into real ids; provider types with no catalog
* (aggregators, custom) pass an empty map and surface only their exact
* allow entries.
*/
catalog: Map<string, CatalogModelMetadata>;
/** Keyed by model key, for display names and creation times. */
configured: Map<string, ConfiguredModel>;
};
export type ModelPage = {
data: AnthropicModelInfo[];
has_more: boolean;
};
/**
* Expands one provider's effective allow/block lists into concrete model ids.
* Two sources feed the candidate set: exact (non-wildcard) allow entries, which
* are already concrete ids, and the catalog for the provider's type, which is
* what makes wildcard allows enumerable. Every candidate is then run back
* through the same allow/block check the inference pipeline applies, so a block
* pattern hides a model here exactly as it would reject it at request time.
*/
export function expandProviderModels(
provider: ModelDiscoveryProvider
): AnthropicModelInfo[] {
const candidates = new Set<string>();
for (const allow of provider.allows) {
if (!isModelKeyPattern(allow)) {
candidates.add(allow);
}
}
for (const modelId of provider.catalog.keys()) {
candidates.add(modelId);
}
const models: AnthropicModelInfo[] = [];
for (const modelKey of candidates) {
if (!isAllowedByLists(modelKey, provider.allows, provider.blocks)) {
continue;
}
const configured = provider.configured.get(modelKey);
const catalog = provider.catalog.get(modelKey);
models.push({
type: "model",
id: modelKey,
display_name: configured?.name || modelKey,
created_at: configured
? new Date(configured.createdAt).toISOString()
: UNKNOWN_CREATED_AT,
max_input_tokens: catalog?.maxInputTokens ?? null,
max_tokens: catalog?.maxOutputTokens ?? null,
capabilities: catalog
? capabilitiesFromCatalog(catalog.capabilities)
: null
});
}
return models;
}
/**
* Aggregates the permitted models across every provider attached to a
* resource. Unlike an inference request there is no requested model to
* disambiguate on, so no provider selection happens - the listing is the union
* of what each provider would accept, deduplicated by model id.
*/
export function listPermittedModels(
providers: ModelDiscoveryProvider[]
): AnthropicModelInfo[] {
const byModelId = new Map<string, AnthropicModelInfo>();
// Sorted so a model offered by two providers always resolves to the same
// entry, which keeps the cursor ordering stable across requests.
const ordered = [...providers].sort((a, b) => a.providerId - b.providerId);
for (const provider of ordered) {
for (const model of expandProviderModels(provider)) {
if (!byModelId.has(model.id)) {
byModelId.set(model.id, model);
}
}
}
// "More recently released models are listed first" per the Models API,
// with the id as a tie-break so the ordering is total - cursor pagination
// needs it to be stable between calls.
return [...byModelId.values()].sort((a, b) => {
const byCreated = b.created_at.localeCompare(a.created_at);
return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id);
});
}
/**
* Applies Anthropic's cursor pagination to an ordered model list. `after_id`
* returns the page immediately after that model, `before_id` the page
* immediately before it. Returns an error message for a caller mistake
* (both cursors, or a cursor naming a model that isn't in the list).
*/
export function paginateModels(
models: AnthropicModelInfo[],
limit: number,
cursor: { afterId?: string; beforeId?: string }
): ModelPage | { error: string } {
if (cursor.afterId && cursor.beforeId) {
return { error: "Only one of after_id and before_id may be provided" };
}
const cursorId = cursor.afterId ?? cursor.beforeId;
if (!cursorId) {
return {
data: models.slice(0, limit),
has_more: models.length > limit
};
}
const index = models.findIndex((model) => model.id === cursorId);
if (index === -1) {
return { error: `Unknown cursor id "${cursorId}"` };
}
if (cursor.afterId) {
const start = index + 1;
return {
data: models.slice(start, start + limit),
has_more: models.length > start + limit
};
}
const start = Math.max(0, index - limit);
return {
data: models.slice(start, index),
has_more: start > 0
};
}
+2
View File
@@ -335,6 +335,8 @@ const EXTRACTORS: Record<
openai_chat: extractOpenAiChat,
openai_responses: extractOpenAiResponses,
anthropic_messages: extractAnthropicMessages,
// Model discovery never runs a model, so there are no tokens to bill.
v1_models: () => null,
gemini_generate_content: extractGoogleGenerateContent,
google_generate_content: extractGoogleGenerateContent,
// rawPredict is a passthrough to whatever the underlying publisher
+2
View File
@@ -9,6 +9,7 @@ export enum TierFeature {
AccessLogs = "accessLogs", // set the retention period to none on downgrade
ActionLogs = "actionLogs", // set the retention period to none on downgrade
ConnectionLogs = "connectionLogs",
AISessionLogs = "aiSessionLogs",
RotateCredentials = "rotateCredentials",
MaintenancePage = "maintenancePage", // handle downgrade
DevicePosture = "devicePosture",
@@ -37,6 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
+85 -74
View File
@@ -1,5 +1,6 @@
import {
db,
primaryDb,
newts,
blueprints,
Blueprint,
@@ -80,93 +81,103 @@ export async function applyBlueprint({
trx,
siteId
);
});
// We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, target.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
// Push updates to newts/clients only after the transaction has
// committed. Doing this while the transaction is still open can
// race with the writes (e.g. newts requesting config before the
// new targets/resources are actually visible), leaving them out
// of sync until manually toggled.
// We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) {
for (const target of result.targetsToUpdate) {
// read from the primary: this determines whether/how we push
// the just-created target to the newt, so a lagging replica
// returning stale or missing data here would silently skip
// the push
const [site] = await primaryDb
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, target.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
.limit(1);
)
.limit(1);
if (site) {
logger.debug(
`Updating target ${target.targetId} on site ${site.sites.siteId}`
if (site) {
logger.debug(
`Updating target ${target.targetId} on site ${site.sites.siteId}`
);
// see if you can find a matching target health check from the healthchecksToUpdate array
const matchingHealthcheck =
result.healthchecksToUpdate.find(
(hc) => hc.targetId === target.targetId
);
// see if you can find a matching target health check from the healthchecksToUpdate array
const matchingHealthcheck =
result.healthchecksToUpdate.find(
(hc) => hc.targetId === target.targetId
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
}
}
}
logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
);
logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
// We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
});
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
blueprintSucceeded = true;
blueprintMessage = "Blueprint applied successfully";
+4 -1
View File
@@ -22,7 +22,10 @@ export async function listExitNodes(
// Accepted for parity with the enterprise implementation (used there for
// site-label filtering of remote exit nodes). The OSS build has no remote
// exit nodes, so it is unused here.
siteId?: number
siteId?: number,
// Same as above: accepted for parity, unused since the OSS build has no
// remote exit nodes to exclude.
noRemote = false
) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db
+27 -14
View File
@@ -1,20 +1,26 @@
import { db, exitNodes, Transaction } from "@server/db";
import { db, exitNodes, exitNodeOrgs, Transaction } from "@server/db";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
import { eq } from "drizzle-orm";
/**
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* which org(s) they belong to - since HA exit nodes can end up routing for
* the same org. This acquires a lock that the caller MUST release (via the
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
* There isn't enough address space to give every exit node in every org a
* globally unique subnet, so we only guarantee uniqueness among exit nodes
* that already belong to the same org - that's all that actually matters,
* since HA only routes multiple exit nodes for a single org. Pass `orgId` to
* scope the search to that org's existing exit nodes; without it, the search
* considers every exit node (used by flows with no org context, e.g. the
* initial gerbil exit node bootstrap). This acquires a lock that the caller
* MUST release (via the returned `release`) only after the chosen address
* has been durably persisted (e.g. after the enclosing transaction commits),
* otherwise concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
trx: Transaction | typeof db = db,
orgId?: string
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
@@ -24,12 +30,19 @@ export async function getNextAvailableSubnet(
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
// Get all existing subnets from routes table
const existingAddresses = await trx
.select({
address: exitNodes.address
})
.from(exitNodes);
// Get existing subnets, scoped to this org's exit nodes when known
const existingAddresses = orgId
? await trx
.select({ address: exitNodes.address })
.from(exitNodes)
.innerJoin(
exitNodeOrgs,
eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId)
)
.where(eq(exitNodeOrgs.orgId, orgId))
: await trx
.select({ address: exitNodes.address })
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
-1
View File
@@ -348,7 +348,6 @@ export const configSchema = z
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean().optional().default(false),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
site_block_size: z
@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, userOrgRoles, users } from "@server/db";
import logger from "@server/logger";
import type {
+5 -2
View File
@@ -153,7 +153,8 @@ export async function listExitNodes(
orgId: string,
filterOnline = false,
noCloud = false,
siteId?: number
siteId?: number,
noRemote = false
) {
const allExitNodes = await db
.select({
@@ -242,7 +243,9 @@ export async function listExitNodes(
let remoteExitNodesList = allExitNodes.filter(
(node) =>
node.type === "remoteExitNode" && (!filterOnline || node.online)
node.type === "remoteExitNode" &&
!noRemote &&
(!filterOnline || node.online)
);
const gerbilExitNodes = allExitNodes.filter(
(node) =>
@@ -19,7 +19,8 @@ import {
requestAuditLog,
actionAuditLog,
accessAuditLog,
connectionAuditLog
connectionAuditLog,
aiSessionLog
} from "@server/db";
import logger from "@server/logger";
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
@@ -309,6 +310,7 @@ export class LogStreamingManager {
if (dest.sendActionLogs) enabledTypes.push("action");
if (dest.sendAccessLogs) enabledTypes.push("access");
if (dest.sendConnectionLogs) enabledTypes.push("connection");
if (dest.sendAISessionLogs) enabledTypes.push("aiSession");
if (enabledTypes.length === 0) return;
@@ -585,6 +587,13 @@ export class LogStreamingManager {
.where(eq(connectionAuditLog.orgId, orgId));
return row?.maxId ?? 0;
}
case "aiSession": {
const [row] = await logsDb
.select({ maxId: max(aiSessionLog.id) })
.from(aiSessionLog)
.where(eq(aiSessionLog.orgId, orgId));
return row?.maxId ?? 0;
}
}
} catch (err) {
logger.warn(
@@ -670,6 +679,21 @@ export class LogStreamingManager {
.limit(limit)) as Array<
Record<string, unknown> & { id: number }
>;
case "aiSession":
return (await logsDb
.select()
.from(aiSessionLog)
.where(
and(
eq(aiSessionLog.orgId, orgId),
gt(aiSessionLog.id, afterId)
)
)
.orderBy(aiSessionLog.id)
.limit(limit)) as Array<
Record<string, unknown> & { id: number }
>;
}
}
@@ -694,6 +718,14 @@ export class LogStreamingManager {
timestamp =
typeof row.startedAt === "number" ? row.startedAt : 0;
break;
case "aiSession":
// createdAt is stored as epoch milliseconds; normalise to
// epoch seconds to match the other log types.
timestamp =
typeof row.createdAt === "number"
? Math.floor(row.createdAt / 1000)
: 0;
break;
}
const orgId = typeof row.orgId === "string" ? row.orgId : "";
+3 -2
View File
@@ -15,13 +15,14 @@
// Log type identifiers
// ---------------------------------------------------------------------------
export type LogType = "request" | "action" | "access" | "connection";
export type LogType = "request" | "action" | "access" | "connection" | "aiSession";
export const LOG_TYPES: LogType[] = [
"request",
"action",
"access",
"connection"
"connection",
"aiSession"
];
// ---------------------------------------------------------------------------
@@ -0,0 +1,288 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
import type { InferInsertModel } from "drizzle-orm";
import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "#private/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { sanitizeString } from "@server/lib/sanitize";
import type { AiCapability } from "@server/lib/aiCapabilities";
import {
normalizeAiRequest,
normalizeAiResponse
} from "@server/lib/aiMessageNormalization";
// Caps how much of the request/response body we keep per row, so a single
// huge multimodal payload can't blow up buffer memory or storage.
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
// In-memory buffer for batching AI session log inserts, mirroring the
// approach in server/routers/badger/logRequestAudit.ts.
const sessionLogBuffer: AiSessionLogInsert[] = [];
const BATCH_SIZE = 100; // Write to DB every 100 logs
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
let flushTimer: NodeJS.Timeout | null = null;
let isFlushInProgress = false;
/**
* Flush buffered logs to database
*/
async function flushSessionLogs() {
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
return;
}
isFlushInProgress = true;
// Take all current logs and clear buffer
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
try {
// Use a transaction to ensure all inserts succeed or fail together
await logsDb.transaction(async (tx) => {
// Batch insert logs in groups of 25 to avoid overwhelming the database
const BATCH_DB_SIZE = 25;
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
await tx.insert(aiSessionLog).values(batch);
}
});
logger.debug(
`Flushed ${logsToWrite.length} AI session logs to database`
);
} catch (error) {
logger.error("Error flushing AI session logs:", error);
// On transaction error, put logs back at the front of the buffer to retry
// but only if buffer isn't too large
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
sessionLogBuffer.unshift(...logsToWrite);
logger.info(
`Re-queued ${logsToWrite.length} AI session logs for retry`
);
} else {
logger.error(
`Buffer full, dropped ${logsToWrite.length} AI session logs`
);
}
} finally {
isFlushInProgress = false;
// If buffer filled up while we were flushing, flush again
if (sessionLogBuffer.length >= BATCH_SIZE) {
flushSessionLogs().catch((err) =>
logger.error("Error in follow-up AI session log flush:", err)
);
}
}
}
/**
* Schedule a flush if not already scheduled
*/
function scheduleFlush() {
if (flushTimer === null) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushSessionLogs().catch((err) =>
logger.error("Error in scheduled AI session log flush:", err)
);
}, BATCH_INTERVAL_MS);
}
}
/**
* Gracefully flush all pending logs (call this on shutdown)
*/
export async function shutdownAiSessionLogger() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
// Force flush even if one is in progress by waiting and retrying
while (isFlushInProgress) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await flushSessionLogs();
}
async function getRetentionDays(orgId: string): Promise<number> {
// check cache first
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
if (cached !== undefined) {
return cached;
}
const [org] = await db
.select({
settingsLogRetentionDaysAISessions:
orgs.settingsLogRetentionDaysAISessions
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return 0;
}
// store the result in cache
await cache.set(
`org_${orgId}_aiSessionsDays`,
org.settingsLogRetentionDaysAISessions,
300
);
return org.settingsLogRetentionDaysAISessions;
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
// match aiUsageRecords - convert before comparing.
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
try {
await logsDb
.delete(aiSessionLog)
.where(
and(
lt(aiSessionLog.createdAt, cutoffTimestampMs),
eq(aiSessionLog.orgId, orgId)
)
);
} catch (error) {
logger.error("Error cleaning up old AI session logs:", error);
}
}
function truncateBody(value: string): { value: string; truncated: boolean } {
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
return { value, truncated: false };
}
return {
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
truncated: true
};
}
export function logAiSession(data: {
sessionId: string;
capability: AiCapability;
provider: AiProvider;
requestedModel: string | undefined;
requestBody: unknown;
responseText: string;
isStream: boolean;
statusCode: number;
orgId: string | null;
resourceId: number | null;
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
}): void {
(async () => {
try {
// Check retention before buffering any logs
if (data.orgId) {
const retentionDays = await getRetentionDays(data.orgId);
if (retentionDays === 0) {
// do not log
return;
}
} else {
// No org resolved for this request - nothing to govern
// retention with, so don't log it.
return;
}
const requestBodyText = truncateBody(
JSON.stringify(data.requestBody ?? "")
);
const responseBodyText = truncateBody(data.responseText ?? "");
// Uniform, capability-agnostic transcript for search/display -
// computed from the untruncated originals so normalization sees
// the full content; the normalized result gets its own
// (typically much smaller) truncation pass below.
const normalizedRequestMessages = normalizeAiRequest(
data.capability,
data.requestBody
);
const normalizedResponseMessages = normalizeAiResponse(
data.capability,
data.responseText ?? "",
data.isStream
);
const normalizedRequestText = normalizedRequestMessages
? truncateBody(JSON.stringify(normalizedRequestMessages))
: null;
const normalizedResponseText = normalizedResponseMessages
? truncateBody(JSON.stringify(normalizedResponseMessages))
: null;
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
logger.warn(
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
);
}
sessionLogBuffer.push({
sessionId: data.sessionId,
orgId: sanitizeString(data.orgId),
providerId: data.provider.providerId,
capability: data.capability,
resourceId: data.resourceId ?? undefined,
siteResourceId: data.siteResourceId ?? undefined,
userId: sanitizeString(data.requestUserId ?? undefined),
virtualApiKeyId: sanitizeString(
data.virtualApiKeyId ?? undefined
),
requestedModel: sanitizeString(data.requestedModel),
isStream: data.isStream,
requestBody: sanitizeString(requestBodyText.value),
responseBody: sanitizeString(responseBodyText.value),
normalizedRequest: normalizedRequestText
? sanitizeString(normalizedRequestText.value)
: undefined,
normalizedResponse: normalizedResponseText
? sanitizeString(normalizedResponseText.value)
: undefined,
truncated:
requestBodyText.truncated ||
responseBodyText.truncated ||
(normalizedRequestText?.truncated ?? false) ||
(normalizedResponseText?.truncated ?? false),
statusCode: data.statusCode,
createdAt: Date.now()
});
// Flush immediately if buffer is full, otherwise schedule a flush
if (sessionLogBuffer.length >= BATCH_SIZE) {
flushSessionLogs().catch((err) =>
logger.error("Error flushing AI session logs:", err)
);
} else {
scheduleFlush();
}
} catch (error) {
logger.error("Failed to log AI session", { error });
}
})();
}
@@ -291,6 +291,10 @@ async function disableFeature(
await disableConnectionLogs(orgId);
break;
case TierFeature.AISessionLogs:
await disableAISessionLogs(orgId);
break;
case TierFeature.RotateCredentials:
await disableRotateCredentials(orgId);
break;
@@ -493,6 +497,15 @@ async function disableConnectionLogs(orgId: string): Promise<void> {
logger.info(`Disabled connection logs for org ${orgId}`);
}
async function disableAISessionLogs(orgId: string): Promise<void> {
await db
.update(orgs)
.set({ settingsLogRetentionDaysAISessions: 0 })
.where(eq(orgs.orgId, orgId));
logger.info(`Disabled AI session logs for org ${orgId}`);
}
async function disableRotateCredentials(orgId: string): Promise<void> {}
async function disablemaintenancePage(orgId: string): Promise<void> {
@@ -37,7 +37,8 @@ const bodySchema = z.strictObject({
sendConnectionLogs: z.boolean().optional().default(false),
sendRequestLogs: z.boolean().optional().default(false),
sendActionLogs: z.boolean().optional().default(false),
sendAccessLogs: z.boolean().optional().default(false)
sendAccessLogs: z.boolean().optional().default(false),
sendAISessionLogs: z.boolean().optional().default(false)
});
export type CreateEventStreamingDestinationResponse = {
@@ -122,7 +123,8 @@ export async function createEventStreamingDestination(
sendAccessLogs: parsedBody.data.sendAccessLogs,
sendActionLogs: parsedBody.data.sendActionLogs,
sendConnectionLogs: parsedBody.data.sendConnectionLogs,
sendRequestLogs: parsedBody.data.sendRequestLogs
sendRequestLogs: parsedBody.data.sendRequestLogs,
sendAISessionLogs: parsedBody.data.sendAISessionLogs
})
.returning();
@@ -60,6 +60,7 @@ export type ListEventStreamingDestinationsResponse = {
sendRequestLogs: boolean;
sendActionLogs: boolean;
sendAccessLogs: boolean;
sendAISessionLogs: boolean;
}[];
pagination: {
total: number;
@@ -83,7 +84,8 @@ const ListEventStreamingDestinationsResponseDataSchema = z.object({
sendConnectionLogs: z.boolean(),
sendRequestLogs: z.boolean(),
sendActionLogs: z.boolean(),
sendAccessLogs: z.boolean()
sendAccessLogs: z.boolean(),
sendAISessionLogs: z.boolean()
})
),
pagination: z.object({
@@ -40,7 +40,8 @@ const bodySchema = z.strictObject({
sendConnectionLogs: z.boolean().optional(),
sendRequestLogs: z.boolean().optional(),
sendActionLogs: z.boolean().optional(),
sendAccessLogs: z.boolean().optional()
sendAccessLogs: z.boolean().optional(),
sendAISessionLogs: z.boolean().optional()
});
export type UpdateEventStreamingDestinationResponse = {
@@ -125,7 +126,7 @@ export async function updateEventStreamingDestination(
);
}
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs, sendAISessionLogs } = parsedBody.data;
const updateData: Record<string, unknown> = {
updatedAt: Date.now()
@@ -141,6 +142,7 @@ export async function updateEventStreamingDestination(
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
if (sendConnectionLogs !== undefined) updateData.sendConnectionLogs = sendConnectionLogs;
if (sendRequestLogs !== undefined) updateData.sendRequestLogs = sendRequestLogs;
if (sendAISessionLogs !== undefined) updateData.sendAISessionLogs = sendAISessionLogs;
await db
.update(eventStreamingDestinations)
+23
View File
@@ -21,6 +21,10 @@ import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license";
import * as generateLicense from "#private/routers/generatedLicense";
import * as logs from "#private/routers/auditLogs";
import {
queryAiSessionLogs,
exportAiSessionLogs
} from "@server/routers/auditLogs";
import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals";
@@ -591,6 +595,25 @@ authenticated.get(
logs.exportConnectionAuditLogs
);
authenticated.get(
"/org/:orgId/logs/ai",
verifyValidLicense,
verifyValidSubscription(tierMatrix.aiSessionLogs),
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyValidLicense,
verifyValidSubscription(tierMatrix.aiSessionLogs),
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
exportAiSessionLogs
);
authenticated.post(
"/re-key/:clientId/regenerate-client-secret",
verifyClientAccess, // this is first to set the org id
@@ -34,10 +34,6 @@ export async function createExitNode(
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
@@ -48,7 +44,7 @@ export async function createExitNode(
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
endpoint: config.getRawConfig().gerbil.base_endpoint,
address,
listenPort,
online: true,
+23
View File
@@ -43,6 +43,10 @@ import {
unauthenticated as ua,
authenticated as a
} from "@server/routers/integration";
import {
queryAiSessionLogs,
exportAiSessionLogs
} from "@server/routers/auditLogs";
import { logActionAudit } from "#private/middlewares";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
@@ -153,6 +157,25 @@ authenticated.get(
logs.exportConnectionAuditLogs
);
authenticated.get(
"/org/:orgId/logs/ai",
verifyValidLicense,
verifyValidSubscription(tierMatrix.aiSessionLogs),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyValidLicense,
verifyValidSubscription(tierMatrix.aiSessionLogs),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
exportAiSessionLogs
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
@@ -1,238 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
const zlibInflate = promisify(inflate);
interface HTTPRequestLogData {
requestId: string;
resourceId: number; // siteResourceId
timestamp: string; // ISO 8601
method: string;
scheme: string; // "http" or "https"
host: string;
path: string;
rawQuery?: string;
userAgent?: string;
sourceAddr: string; // ip:port
tls: boolean;
}
/**
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
*/
async function decompressRequestLog(
compressed: string
): Promise<HTTPRequestLogData[]> {
const compressedBuffer = Buffer.from(compressed, "base64");
const decompressed = await zlibInflate(compressedBuffer);
const jsonString = decompressed.toString("utf-8");
const parsed = JSON.parse(jsonString);
if (!Array.isArray(parsed)) {
throw new Error("Decompressed request log data is not an array");
}
return parsed;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
const { message, client } = context;
const newt = client as Newt;
if (!newt) {
logger.warn("Request log received but no newt client in context");
return;
}
if (!newt.siteId) {
logger.warn("Request log received but newt has no siteId");
return;
}
if (!message.data?.compressed) {
logger.warn("Request log message missing compressed data");
return;
}
// Look up the org for this site and check retention settings
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn(
`Request log received but site ${newt.siteId} not found in database`
);
return;
}
const orgId = site.orgId;
if (site.settingsLogRetentionDaysRequest === 0) {
logger.debug(
`Request log retention is disabled for org ${orgId}, skipping`
);
return;
}
let entries: HTTPRequestLogData[];
try {
entries = await decompressRequestLog(message.data.compressed);
} catch (error) {
logger.error("Failed to decompress request log data:", error);
return;
}
if (entries.length === 0) {
return;
}
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
if (c.endpoint) {
const ip = c.subnet.split("/")[0];
ipToEndpoint.set(ip, c.endpoint);
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
!entry.resourceId ||
!entry.method ||
!entry.scheme ||
!entry.host ||
!entry.path ||
!entry.sourceAddr
) {
logger.debug(
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
);
continue;
}
const originalRequestURL =
entry.scheme +
"://" +
entry.host +
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
await logRequestAudit(
{
action: true,
reason: 108,
siteResourceId: entry.resourceId,
orgId,
location
},
{
path: entry.path,
originalRequestURL,
scheme: entry.scheme,
host: entry.host,
method: entry.method,
tls: entry.tls,
requestIp: entry.sourceAddr
}
);
}
logger.debug(
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
);
};
-1
View File
@@ -12,4 +12,3 @@
*/
export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";
@@ -191,13 +191,20 @@ export async function createRemoteExitNode(
// If this remote exit node isn't already backing an exit node in
// another org, we're about to create a brand new one. Reserve a
// subnet for it up front so the allocation lock is held across the
// whole insert - this guarantees exit node subnets never overlap,
// even under concurrent creation, which matters for HA setups.
// subnet for it up front, scoped to this org's existing exit nodes,
// so the allocation lock is held across the whole insert - this
// guarantees exit node subnets never overlap within the org, even
// under concurrent creation, which matters for HA setups. Subnets
// may still be reused across different orgs; there isn't enough
// address space to avoid that, and it isn't necessary since HA only
// routes multiple exit nodes for the same org.
let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null;
if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet();
const { value, release } = await getNextAvailableSubnet(
db,
orgId
);
newExitNodeAddress = value;
releaseSubnetLock = release;
}
@@ -18,12 +18,10 @@ import {
import { MessageHandler } from "@server/routers/ws";
import {
handleConnectionLogMessage,
handleRequestLogMessage
} from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage,
"newt/request-log": handleRequestLogMessage
};
+1 -1
View File
@@ -139,7 +139,7 @@ const processMessage = async (
}
}
} catch (error) {
logger.error("Message handling error:", error);
logger.warn("Message handling error:", error);
// ws.send(JSON.stringify({
// type: "error",
// data: {
@@ -1,19 +1,37 @@
import { Router } from "express";
import { Router, type Request, type Response } from "express";
import {
AI_CAPABILITY_DEFS,
type AiCapability
} from "@server/lib/aiCapabilities";
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
import { handleV1Models } from "@server/routers/aiGateway";
type CapabilityHandler = (
req: Request,
res: Response,
capability: AiCapability
) => Promise<any>;
// Capabilities the gateway answers itself instead of proxying upstream.
// Everything else goes through the inference pipeline.
const LOCAL_HANDLERS: Partial<Record<AiCapability, CapabilityHandler>> = {
v1_models: handleV1Models
};
export function createAiGatewayRouter() {
const router = Router();
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
const capability = def.id as AiCapability;
const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy;
for (const route of def.routes) {
router.post(route.path, (req, res) =>
handleAiGatewayProxy(req, res, capability)
);
const bind = (req: Request, res: Response) =>
handler(req, res, capability);
if (route.method === "GET") {
router.get(route.path, bind);
} else {
router.post(route.path, bind);
}
}
}
+1
View File
@@ -1,2 +1,3 @@
export { handleAiGatewayProxy } from "./pipeline";
export { handleV1Models } from "./v1Models";
export { createAiGatewayRouter } from "./createAiGatewayRouter";
+5 -255
View File
@@ -1,171 +1,12 @@
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
import type { InferInsertModel } from "drizzle-orm";
import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "#dynamic/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { sanitizeString } from "@server/lib/sanitize";
import type { AiCapability } from "@server/lib/aiCapabilities";
import {
normalizeAiRequest,
normalizeAiResponse
} from "@server/lib/aiMessageNormalization";
// Caps how much of the request/response body we keep per row, so a single
// huge multimodal payload can't blow up buffer memory or storage.
const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000;
type AiSessionLogInsert = InferInsertModel<typeof aiSessionLog>;
// In-memory buffer for batching AI session log inserts, mirroring the
// approach in server/routers/badger/logRequestAudit.ts.
const sessionLogBuffer: AiSessionLogInsert[] = [];
const BATCH_SIZE = 100; // Write to DB every 100 logs
const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first
const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth
let flushTimer: NodeJS.Timeout | null = null;
let isFlushInProgress = false;
/**
* Flush buffered logs to database
*/
async function flushSessionLogs() {
if (sessionLogBuffer.length === 0 || isFlushInProgress) {
return;
}
isFlushInProgress = true;
// Take all current logs and clear buffer
const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length);
try {
// Use a transaction to ensure all inserts succeed or fail together
await logsDb.transaction(async (tx) => {
// Batch insert logs in groups of 25 to avoid overwhelming the database
const BATCH_DB_SIZE = 25;
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
await tx.insert(aiSessionLog).values(batch);
}
});
logger.debug(
`Flushed ${logsToWrite.length} AI session logs to database`
);
} catch (error) {
logger.error("Error flushing AI session logs:", error);
// On transaction error, put logs back at the front of the buffer to retry
// but only if buffer isn't too large
if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
sessionLogBuffer.unshift(...logsToWrite);
logger.info(
`Re-queued ${logsToWrite.length} AI session logs for retry`
);
} else {
logger.error(
`Buffer full, dropped ${logsToWrite.length} AI session logs`
);
}
} finally {
isFlushInProgress = false;
// If buffer filled up while we were flushing, flush again
if (sessionLogBuffer.length >= BATCH_SIZE) {
flushSessionLogs().catch((err) =>
logger.error("Error in follow-up AI session log flush:", err)
);
}
}
}
/**
* Schedule a flush if not already scheduled
*/
function scheduleFlush() {
if (flushTimer === null) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushSessionLogs().catch((err) =>
logger.error("Error in scheduled AI session log flush:", err)
);
}, BATCH_INTERVAL_MS);
}
}
import { AiCapability } from "@app/lib/aiCapabilities";
import { AiProvider } from "@server/db";
/**
* Gracefully flush all pending logs (call this on shutdown)
*/
export async function shutdownAiSessionLogger() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
// Force flush even if one is in progress by waiting and retrying
while (isFlushInProgress) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await flushSessionLogs();
}
export async function shutdownAiSessionLogger() {}
async function getRetentionDays(orgId: string): Promise<number> {
// check cache first
const cached = await cache.get<number>(`org_${orgId}_aiSessionsDays`);
if (cached !== undefined) {
return cached;
}
const [org] = await db
.select({
settingsLogRetentionDaysAISessions:
orgs.settingsLogRetentionDaysAISessions
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return 0;
}
// store the result in cache
await cache.set(
`org_${orgId}_aiSessionsDays`,
org.settingsLogRetentionDaysAISessions,
300
);
return org.settingsLogRetentionDaysAISessions;
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
// match aiUsageRecords - convert before comparing.
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
try {
await logsDb
.delete(aiSessionLog)
.where(
and(
lt(aiSessionLog.createdAt, cutoffTimestampMs),
eq(aiSessionLog.orgId, orgId)
)
);
} catch (error) {
logger.error("Error cleaning up old AI session logs:", error);
}
}
function truncateBody(value: string): { value: string; truncated: boolean } {
if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) {
return { value, truncated: false };
}
return {
value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS),
truncated: true
};
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {}
export function logAiSession(data: {
sessionId: string;
@@ -181,95 +22,4 @@ export function logAiSession(data: {
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
}): void {
(async () => {
try {
// Check retention before buffering any logs
if (data.orgId) {
const retentionDays = await getRetentionDays(data.orgId);
if (retentionDays === 0) {
// do not log
return;
}
} else {
// No org resolved for this request - nothing to govern
// retention with, so don't log it.
return;
}
const requestBodyText = truncateBody(
JSON.stringify(data.requestBody ?? "")
);
const responseBodyText = truncateBody(data.responseText ?? "");
// Uniform, capability-agnostic transcript for search/display -
// computed from the untruncated originals so normalization sees
// the full content; the normalized result gets its own
// (typically much smaller) truncation pass below.
const normalizedRequestMessages = normalizeAiRequest(
data.capability,
data.requestBody
);
const normalizedResponseMessages = normalizeAiResponse(
data.capability,
data.responseText ?? "",
data.isStream
);
const normalizedRequestText = normalizedRequestMessages
? truncateBody(JSON.stringify(normalizedRequestMessages))
: null;
const normalizedResponseText = normalizedResponseMessages
? truncateBody(JSON.stringify(normalizedResponseMessages))
: null;
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
logger.warn(
`AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
);
}
sessionLogBuffer.push({
sessionId: data.sessionId,
orgId: sanitizeString(data.orgId),
providerId: data.provider.providerId,
capability: data.capability,
resourceId: data.resourceId ?? undefined,
siteResourceId: data.siteResourceId ?? undefined,
userId: sanitizeString(data.requestUserId ?? undefined),
virtualApiKeyId: sanitizeString(
data.virtualApiKeyId ?? undefined
),
requestedModel: sanitizeString(data.requestedModel),
isStream: data.isStream,
requestBody: sanitizeString(requestBodyText.value),
responseBody: sanitizeString(responseBodyText.value),
normalizedRequest: normalizedRequestText
? sanitizeString(normalizedRequestText.value)
: undefined,
normalizedResponse: normalizedResponseText
? sanitizeString(normalizedResponseText.value)
: undefined,
truncated:
requestBodyText.truncated ||
responseBodyText.truncated ||
(normalizedRequestText?.truncated ?? false) ||
(normalizedResponseText?.truncated ?? false),
statusCode: data.statusCode,
createdAt: Date.now()
});
// Flush immediately if buffer is full, otherwise schedule a flush
if (sessionLogBuffer.length >= BATCH_SIZE) {
flushSessionLogs().catch((err) =>
logger.error("Error flushing AI session logs:", err)
);
} else {
scheduleFlush();
}
} catch (error) {
logger.error("Failed to log AI session", { error });
}
})();
}
}): void {}
+20 -11
View File
@@ -86,7 +86,7 @@ import {
type AiUsage
} from "@server/lib/aiUsageExtraction";
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
import { logAiSession } from "@server/routers/aiGateway/logAiSession";
import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession";
const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges";
const EXIT_NODE_RANGES_TTL_SEC = 6000;
@@ -137,7 +137,7 @@ async function findClientByIp(ip: string): Promise<CachedClient> {
return result;
}
type ProviderAttachment = {
export type ProviderAttachment = {
provider: AiProvider;
accessMode: AccessMode;
};
@@ -149,12 +149,12 @@ type ResourceModelPattern = {
enabled: boolean;
};
type ProviderPatternLists = {
export type ProviderPatternLists = {
allows: string[];
blocks: string[];
};
type ResolvedTarget = {
export type ResolvedTarget = {
resourceId: number | null;
siteResourceId: number | null;
orgId: string | null;
@@ -362,7 +362,7 @@ function getRequestHeader(req: Request, name: string): string | undefined {
// request came through, per the trust middleware's resource-type header -
// falls back to checking both (public preferred on overlap) only when that
// header is absent, e.g. a request that reached the gateway outside Traefik.
async function resolveTarget(
export async function resolveTarget(
host: string,
resourceType: AiGatewayResourceType | null
): Promise<ResolvedTarget | null> {
@@ -728,7 +728,9 @@ export function recordAiGatewayCompletion(args: {
let cost: ReturnType<typeof calculateAiCost> = null;
if (upstreamSucceeded) {
usage = extractUsage(capability, responseText, isStream, headers) ?? emptyUsage();
usage =
extractUsage(capability, responseText, isStream, headers) ??
emptyUsage();
if (isUsageEmpty(usage)) {
usage = estimateUsage(
JSON.stringify(requestBody ?? ""),
@@ -810,6 +812,17 @@ export function recordAiGatewayCompletion(args: {
});
}
// p-host is only used sometimes when overriding the host header for some
// middleware proxy. Shared with the model-discovery endpoint so both resolve
// the inference resource off the same hostname.
export function resolveGatewayHost(req: Request): string {
return (
(req.headers["p-host"] as string | undefined) ||
req.headers.host ||
""
).split(":")[0];
}
export async function handleAiGatewayProxy(
req: Request,
res: Response,
@@ -818,11 +831,7 @@ export async function handleAiGatewayProxy(
try {
const def = AI_CAPABILITY_DEFS[capability];
const host = (
(req.headers["p-host"] as string | undefined) || // p-host is only used sometimes when overriding the host header for some middleware proxy
req.headers.host ||
""
).split(":")[0];
const host = resolveGatewayHost(req);
if (!host) {
return res
.status(HttpCode.BAD_REQUEST)
@@ -99,6 +99,7 @@ async function fetchProviderTargets(
method: targets.method,
exitNodeSubnet: sites.exitNodeSubnet,
reachableAt: exitNodes.reachableAt,
exitNodeType: exitNodes.type,
hcHealth: targetHealthCheck.hcHealth
})
.from(targets)
@@ -119,6 +120,12 @@ async function fetchProviderTargets(
if (!row.exitNodeSubnet || !row.reachableAt) {
continue;
}
// Sites connected to a remote exit node aren't reachable via a
// gerbil sidecar's /router/* proxy - only "gerbil" type exit nodes
// run that endpoint.
if (row.exitNodeType !== "gerbil") {
continue;
}
// A target with an active health check that's currently failing is
// taken out of rotation. No health check (null) or "unknown" (check
// hasn't run yet / hcEnabled is off) still routes normally, matching
+308
View File
@@ -0,0 +1,308 @@
import { Request, Response } from "express";
import { inArray } from "drizzle-orm";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import {
providerHasCapability,
type AiCapability
} from "@server/lib/aiCapabilities";
import {
buildAiCapabilityErrorBody,
type AiCapabilityErrorKind
} from "@server/lib/aiGatewayAuthError";
import {
getAiGatewayResourceType,
isAiGatewayTrustHeaderValid
} from "@server/lib/aiGatewayTrust";
import { resolveEffectiveLists } from "@server/lib/aiInferenceResource";
import { listCatalogEntriesForType } from "@server/lib/aiModelCatalog";
import {
listPermittedModels,
paginateModels,
MODEL_PAGE_DEFAULT_LIMIT,
MODEL_PAGE_MAX_LIMIT,
type CatalogModelMetadata,
type ConfiguredModel,
type ModelDiscoveryProvider
} from "@server/lib/aiModelDiscovery";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import {
resolveGatewayHost,
resolveTarget,
type ProviderAttachment,
type ProviderPatternLists
} from "@server/routers/aiGateway/pipeline";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
const CAPABILITY: AiCapability = "v1_models";
const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(),
after_id: z.string().min(1).optional(),
before_id: z.string().min(1).optional()
});
type ProviderModelLists = {
allowsByProvider: Map<number, string[]>;
blocksByProvider: Map<number, string[]>;
configuredByProvider: Map<number, Map<string, ConfiguredModel>>;
};
function errorResponse(
res: Response,
status: number,
kind: AiCapabilityErrorKind,
message: string
) {
return res
.status(status)
.json(buildAiCapabilityErrorBody(CAPABILITY, kind, message, status));
}
// Provider-level allow/block lists, plus the display name and creation time of
// every catalog row, so explicitly configured models are reported with the name
// the administrator gave them rather than a bare model id.
async function loadProviderModelLists(
providerIds: number[]
): Promise<ProviderModelLists> {
const lists: ProviderModelLists = {
allowsByProvider: new Map(),
blocksByProvider: new Map(),
configuredByProvider: new Map()
};
if (providerIds.length === 0) {
return lists;
}
const rows = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
name: aiModels.name,
listType: aiModels.listType,
enabled: aiModels.enabled,
createdAt: aiModels.createdAt
})
.from(aiModels)
.where(inArray(aiModels.providerId, providerIds));
for (const row of rows) {
if (!row.enabled) {
continue;
}
const targetMap =
row.listType === "allow"
? lists.allowsByProvider
: lists.blocksByProvider;
const existing = targetMap.get(row.providerId) ?? [];
existing.push(row.modelKey);
targetMap.set(row.providerId, existing);
let configured = lists.configuredByProvider.get(row.providerId);
if (!configured) {
configured = new Map();
lists.configuredByProvider.set(row.providerId, configured);
}
configured.set(row.modelKey, {
name: row.name,
createdAt: row.createdAt
});
}
return lists;
}
function catalogMetadataForType(
type: AiProviderType
): Map<string, CatalogModelMetadata> {
const metadata = new Map<string, CatalogModelMetadata>();
for (const entry of listCatalogEntriesForType(type)) {
metadata.set(entry.model, {
maxInputTokens: entry.limits.input,
maxOutputTokens: entry.limits.output,
capabilities: entry.capabilities
});
}
return metadata;
}
function buildDiscoveryProviders(
attachments: ProviderAttachment[],
resourceListsByProvider: Map<number, ProviderPatternLists>,
lists: ProviderModelLists
): ModelDiscoveryProvider[] {
return attachments.map((attachment) => {
const providerId = attachment.provider.providerId;
const resourceLists = resourceListsByProvider.get(providerId);
const { allows, blocks } = resolveEffectiveLists({
accessMode: attachment.accessMode,
providerAllows: lists.allowsByProvider.get(providerId) ?? [],
providerBlocks: lists.blocksByProvider.get(providerId) ?? [],
resourceAllows: resourceLists?.allows ?? [],
resourceBlocks: resourceLists?.blocks ?? []
});
return {
providerId,
allows,
blocks,
catalog: catalogMetadataForType(
attachment.provider.type as AiProviderType
),
configured: lists.configuredByProvider.get(providerId) ?? new Map()
};
});
}
/**
* Serves Anthropic's model-discovery endpoints (`GET /v1/models` and
* `GET /v1/models/{id}`) for an inference resource. The gateway answers these
* itself rather than proxying: upstream providers either don't expose a model
* list at all or would expose models the resource's allow/block lists forbid,
* so the response is built from the same effective lists that gate inference.
*/
export async function handleV1Models(
req: Request,
res: Response
): Promise<any> {
try {
const host = resolveGatewayHost(req);
if (!host) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
"Missing Host header"
);
}
const resourceType = getAiGatewayResourceType(
req.headers as Record<string, string>
);
const target = await resolveTarget(host, resourceType);
if (!target) {
return errorResponse(
res,
HttpCode.NOT_FOUND,
"not_found",
"No inference resource found for this host"
);
}
// Same gate as the inference pipeline: public inference must pass
// Badger verify-session first, which is what stamps the trust header.
if (
target.resourceId != null &&
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
) {
return errorResponse(
res,
HttpCode.UNAUTHORIZED,
"authentication",
"Request must be authenticated via the inference resource"
);
}
if (target.attachments.length === 0) {
return errorResponse(
res,
HttpCode.FORBIDDEN,
"permission",
"No AI providers configured for this resource"
);
}
const capableAttachments = target.attachments.filter((a) =>
providerHasCapability(a.provider.capabilities, CAPABILITY)
);
if (capableAttachments.length === 0) {
return errorResponse(
res,
HttpCode.FORBIDDEN,
"permission",
`No AI provider on this resource supports ${CAPABILITY}`
);
}
const lists = await loadProviderModelLists(
capableAttachments.map((a) => a.provider.providerId)
);
const models = listPermittedModels(
buildDiscoveryProviders(
capableAttachments,
target.resourceListsByProvider,
lists
)
);
// `GET /v1/models/{id}` - a single model, 404 when this resource
// doesn't permit it.
const requestedModel = req.params?.model;
if (typeof requestedModel === "string" && requestedModel.length > 0) {
const model = models.find((m) => m.id === requestedModel);
if (!model) {
return errorResponse(
res,
HttpCode.NOT_FOUND,
"not_found",
`Model "${requestedModel}" is not available on this resource`
);
}
return res.status(HttpCode.OK).json(model);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
parsedQuery.error.issues[0]?.message ??
"Invalid pagination parameters"
);
}
const page = paginateModels(
models,
parsedQuery.data.limit ?? MODEL_PAGE_DEFAULT_LIMIT,
{
afterId: parsedQuery.data.after_id,
beforeId: parsedQuery.data.before_id
}
);
if ("error" in page) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
page.error
);
}
logger.debug("AI gateway model discovery", {
host,
resourceId: target.resourceId,
siteResourceId: target.siteResourceId,
providers: capableAttachments.length,
total: models.length,
returned: page.data.length
});
return res.status(HttpCode.OK).json({
data: page.data,
has_more: page.has_more,
first_id: page.data[0]?.id ?? null,
last_id: page.data[page.data.length - 1]?.id ?? null
});
} catch (error) {
logger.error(error);
return errorResponse(
res,
HttpCode.INTERNAL_SERVER_ERROR,
"internal",
"Failed to list models"
);
}
}
@@ -23,6 +23,12 @@ export async function createCertificate(
throw new Error(`Domain with ID ${domainId} not found`);
}
// Note: certificates.domain has a global UNIQUE constraint (it is not
// scoped per-domainId), so existence must be checked by domain value
// alone. Filtering on domainId here as well can cause this check to
// miss an existing cert (e.g. if it was stored under a different but
// still-valid domainId), leading to an INSERT that then fails on the
// unique constraint.
let existing: Certificate[] = [];
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
@@ -32,16 +38,13 @@ export async function createCertificate(
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
or(
eq(certificates.domain, domain),
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
or(
eq(certificates.domain, domain),
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
@@ -51,12 +54,7 @@ export async function createCertificate(
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.domain, domain) // exact match for non-NS domains
)
);
.where(eq(certificates.domain, domain)); // exact match for non-NS domains
}
if (existing.length > 0) {
@@ -87,16 +85,22 @@ export async function createCertificate(
}
}
// No cert found, create a new one in pending state
await trx.insert(certificates).values({
domain: domainToWrite,
domainId,
wildcard:
domainRecord.type == "ns" ||
(domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)
});
// No cert found, create a new one in pending state. onConflictDoNothing
// guards against the domain having been inserted concurrently (or under
// a different domainId) between the existence check above and this
// insert, since certificates.domain is globally unique.
await trx
.insert(certificates)
.values({
domain: domainToWrite,
domainId,
wildcard:
domainRecord.type == "ns" ||
(domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)
})
.onConflictDoNothing();
}
-15
View File
@@ -1490,21 +1490,6 @@ authenticated.get(
logs.exportRequestAuditLogs
);
authenticated.get(
"/org/:orgId/logs/ai",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/usage/filters",
verifyOrgAccess,
+1 -7
View File
@@ -15,13 +15,7 @@ export async function createExitNode(
if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
@@ -32,7 +26,7 @@ export async function createExitNode(
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
endpoint: config.getRawConfig().gerbil.base_endpoint,
address,
online: true,
listenPort,
-15
View File
@@ -1532,21 +1532,6 @@ authenticated.get(
logs.exportRequestAuditLogs
);
authenticated.get(
"/org/:orgId/logs/ai",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/usage/filters",
verifyApiKeyOrgAccess,
+241 -2
View File
@@ -1,9 +1,248 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
const zlibInflate = promisify(inflate);
interface HTTPRequestLogData {
requestId: string;
resourceId: number; // siteResourceId
timestamp: string; // ISO 8601
method: string;
scheme: string; // "http" or "https"
host: string;
path: string;
rawQuery?: string;
userAgent?: string;
sourceAddr: string; // ip:port
tls: boolean;
}
/**
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
*/
async function decompressRequestLog(
compressed: string
): Promise<HTTPRequestLogData[]> {
const compressedBuffer = Buffer.from(compressed, "base64");
const decompressed = await zlibInflate(compressedBuffer);
const jsonString = decompressed.toString("utf-8");
const parsed = JSON.parse(jsonString);
if (!Array.isArray(parsed)) {
throw new Error("Decompressed request log data is not an array");
}
return parsed;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
return;
};
const { message, client } = context;
const newt = client as Newt;
if (!newt) {
logger.warn("Request log received but no newt client in context");
return;
}
if (!newt.siteId) {
logger.warn("Request log received but newt has no siteId");
return;
}
if (!message.data?.compressed) {
logger.warn("Request log message missing compressed data");
return;
}
// Look up the org for this site and check retention settings
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn(
`Request log received but site ${newt.siteId} not found in database`
);
return;
}
const orgId = site.orgId;
if (site.settingsLogRetentionDaysRequest === 0) {
logger.debug(
`Request log retention is disabled for org ${orgId}, skipping`
);
return;
}
let entries: HTTPRequestLogData[];
try {
entries = await decompressRequestLog(message.data.compressed);
} catch (error) {
logger.error("Failed to decompress request log data:", error);
return;
}
if (entries.length === 0) {
return;
}
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
// Build a map from sourceIp → the user associated with the client (if any)
const ipToUser = new Map<string, { username: string; userId: string }>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint,
username: users.username,
userId: users.userId
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.leftJoin(users, eq(clients.userId, users.userId))
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
const ip = c.subnet.split("/")[0];
if (c.endpoint) {
ipToEndpoint.set(ip, c.endpoint);
}
if (c.userId && c.username) {
ipToUser.set(ip, { userId: c.userId, username: c.username });
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
!entry.resourceId ||
!entry.method ||
!entry.scheme ||
!entry.host ||
!entry.path ||
!entry.sourceAddr
) {
logger.debug(
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
);
continue;
}
const originalRequestURL =
entry.scheme +
"://" +
entry.host +
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
const user = ipToUser.get(sourceIp);
await logRequestAudit(
{
action: true,
reason: 108,
siteResourceId: entry.resourceId,
orgId,
location,
user
},
{
path: entry.path,
originalRequestURL,
scheme: entry.scheme,
host: entry.host,
method: entry.method,
tls: entry.tls,
requestIp: entry.sourceAddr
}
);
}
logger.debug(
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
);
};
@@ -42,7 +42,8 @@ export const handleOlmExitNodesRequestMessage: MessageHandler = async (
client.orgId,
true,
noCloud || false,
olm.clientId
olm.clientId,
true // don't select remote exit nodes for clients
); // filter for only the online ones
let lastExitNodeId = null;
+36
View File
@@ -147,6 +147,42 @@ export async function updateOrg(
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
}
// Check access logs feature
const hasAccessLogsFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.AccessLogs]
);
if (!hasAccessLogsFeature) {
parsedBody.data.settingsLogRetentionDaysAccess = undefined;
}
// Check action logs feature
const hasActionLogsFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.ActionLogs]
);
if (!hasActionLogsFeature) {
parsedBody.data.settingsLogRetentionDaysAction = undefined;
}
// Check connection logs feature
const hasConnectionLogsFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.ConnectionLogs]
);
if (!hasConnectionLogsFeature) {
parsedBody.data.settingsLogRetentionDaysConnection = undefined;
}
// Check AI session logs feature
const hasAISessionLogsFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.AISessionLogs]
);
if (!hasAISessionLogsFeature) {
parsedBody.data.settingsLogRetentionDaysAISessions = undefined;
}
if (build == "saas") {
const { tier } = await getOrgTierData(orgId);
+38 -3
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp, userResources, users } from "@server/db"; // Assuming these are the correct tables
import { idp, resources, userPolicies, userResources, users } from "@server/db"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -14,7 +14,23 @@ const listResourceUsersSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
async function queryUsers(resourceId: number) {
async function queryUsers(resourceId: number, policyId: number | null) {
if (policyId !== null) {
return await db
.select({
userId: userPolicies.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userPolicies)
.innerJoin(users, eq(userPolicies.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userPolicies.resourcePolicyId, policyId));
}
return await db
.select({
userId: userResources.userId,
@@ -104,7 +120,26 @@ export async function listResourceUsers(
const { resourceId } = parsedParams.data;
const resourceUsersList = await queryUsers(resourceId);
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const resourceUsersList = await queryUsers(
resourceId,
isInlinePolicy ? resource.defaultResourcePolicyId! : null
);
return response<ListResourceUsersResponse>(res, {
data: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/clients/add",
path: "/private-resource/{resourceId}/clients/add",
description:
"Add a single client to a site resource. Clients with a userId cannot be added.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/roles/add",
path: "/private-resource/{resourceId}/roles/add",
description: "Add a single role to a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
request: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/users/add",
path: "/private-resource/{resourceId}/users/add",
description: "Add a single user to a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
request: {
@@ -63,7 +63,7 @@ registry.registerPath({
registry.registerPath({
method: "get",
path: "/private-resource/{siteResourceId}/clients",
path: "/private-resource/{resourceId}/clients",
description: "List all clients for a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
request: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "get",
path: "/private-resource/{siteResourceId}/roles",
path: "/private-resource/{resourceId}/roles",
description: "List all roles for a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
request: {
@@ -67,7 +67,7 @@ registry.registerPath({
registry.registerPath({
method: "get",
path: "/private-resource/{siteResourceId}/users",
path: "/private-resource/{resourceId}/users",
description: "List all users for a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
request: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/clients/remove",
path: "/private-resource/{resourceId}/clients/remove",
description:
"Remove a single client from a site resource. Clients with a userId cannot be removed.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/roles/remove",
path: "/private-resource/{resourceId}/roles/remove",
description: "Remove a single role from a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
request: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/users/remove",
path: "/private-resource/{resourceId}/users/remove",
description: "Remove a single user from a site resource.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
request: {
@@ -64,7 +64,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/clients",
path: "/private-resource/{resourceId}/clients",
description:
"Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Client],
@@ -65,7 +65,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/roles",
path: "/private-resource/{resourceId}/roles",
description:
"Set roles for a site resource. This will replace all existing roles.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.Role],
@@ -66,7 +66,7 @@ registry.registerPath({
registry.registerPath({
method: "post",
path: "/private-resource/{siteResourceId}/users",
path: "/private-resource/{resourceId}/users",
description:
"Set users for a site resource. This will replace all existing users.",
tags: [OpenAPITags.PrivateResource, OpenAPITags.User],
+3 -1
View File
@@ -7,7 +7,8 @@ import {
handleNewtExitNodesRequestMessage,
handleApplyBlueprintMessage,
handleNewtPingMessage,
handleNewtDisconnectingMessage
handleNewtDisconnectingMessage,
handleRequestLogMessage
} from "../newt";
import {
handleOlmRegisterMessage,
@@ -46,5 +47,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/ping/request": handleNewtExitNodesRequestMessage,
"newt/blueprint/apply": handleApplyBlueprintMessage,
"newt/healthcheck/status": handleHealthcheckStatusMessage,
"newt/request-log": handleRequestLogMessage,
"ws/round-trip/complete": handleRoundTripMessage
};
+1 -1
View File
@@ -388,7 +388,7 @@ const setupConnection = async (
}
}
} catch (error) {
logger.error("Message handling error:", error);
logger.warn("Message handling error:", error);
ws.send(
JSON.stringify({
type: "error",
+3
View File
@@ -345,6 +345,9 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "virtualApiKeys" ADD CONSTRAINT "virtualApiKeys_createdByUserId_user_id_fk" FOREIGN KEY ("createdByUserId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "eventStreamingDestinations" ADD "sendAISessionLogs" boolean DEFAULT false NOT NULL;`
);
await db.execute(
sql`CREATE INDEX "idx_ai_budget_breach_events_budget_created" ON "aiBudgetBreachEvents" USING btree ("budgetId","createdAt");`
);
+3
View File
@@ -402,6 +402,9 @@ export default async function migration() {
db.prepare(
`ALTER TABLE 'siteResources' ADD 'requiresExitNodeConnection' integer DEFAULT false NOT NULL;`
).run();
db.prepare(
`ALTER TABLE 'eventStreamingDestinations' ADD 'sendAISessionLogs' integer DEFAULT false NOT NULL;`
).run();
const insertRoleAction = db.prepare(`
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")