first pass of traefik -> basic gateway

This commit is contained in:
Owen
2026-08-02 10:16:36 -04:00
parent 28430dde74
commit cb421f5c41
14 changed files with 795 additions and 21 deletions
+256
View File
@@ -0,0 +1,256 @@
import { Request, Response } from "express";
import { and, eq } from "drizzle-orm";
import {
AiProvider,
aiModels,
aiProviders,
db,
resourceAiModels,
resources,
siteResourceAiModels,
siteResources
} from "@server/db";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
AiProviderAuthType,
AiProviderRoutingMode,
AiProviderType,
resolveAiProviderConfig
} from "@server/lib/aiProviderDefaults";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
type ResolvedTarget = {
provider: AiProvider;
// null = no restriction; every enabled model on the provider is allowed
allowedModelIds: number[] | null;
};
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
const [resourceRow] = await db
.select({ resourceId: resources.resourceId, provider: aiProviders })
.from(resources)
.innerJoin(
aiProviders,
eq(resources.aiProviderId, aiProviders.providerId)
)
.where(
and(
eq(resources.fullDomain, host),
eq(resources.mode, "inference"),
eq(resources.enabled, true),
eq(aiProviders.enabled, true)
)
)
.limit(1);
if (resourceRow) {
const restrictions = await db
.select({ modelId: resourceAiModels.modelId })
.from(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceRow.resourceId));
return {
provider: resourceRow.provider,
allowedModelIds: restrictions.length
? restrictions.map((r) => r.modelId)
: null
};
}
const [siteResourceRow] = await db
.select({
siteResourceId: siteResources.siteResourceId,
provider: aiProviders
})
.from(siteResources)
.innerJoin(
aiProviders,
eq(siteResources.aiProviderId, aiProviders.providerId)
)
.where(
and(
eq(siteResources.alias, host),
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
eq(aiProviders.enabled, true)
)
)
.limit(1);
if (siteResourceRow) {
const restrictions = await db
.select({ modelId: siteResourceAiModels.modelId })
.from(siteResourceAiModels)
.where(
eq(
siteResourceAiModels.siteResourceId,
siteResourceRow.siteResourceId
)
);
return {
provider: siteResourceRow.provider,
allowedModelIds: restrictions.length
? restrictions.map((r) => r.modelId)
: null
};
}
return null;
}
// Generic OpenAI-wire-compatible passthrough. Anthropic's native API uses a
// different path/schema; everything else here is OpenAI-compatible today.
function getCompletionsPath(type: AiProviderType): string {
if (type === "anthropic") {
return "/v1/messages";
}
return "/chat/completions";
}
export async function chatCompletions(req: Request, res: Response): Promise<any> {
try {
const host = (req.headers.host || "").split(":")[0];
if (!host) {
return res
.status(HttpCode.BAD_REQUEST)
.json({ error: { message: "Missing Host header" } });
}
const target = await resolveTarget(host);
if (!target) {
return res.status(HttpCode.NOT_FOUND).json({
error: {
message: "No inference resource found for this host"
}
});
}
const { provider, allowedModelIds } = target;
const requestedModel =
typeof req.body?.model === "string" ? req.body.model : undefined;
if (allowedModelIds) {
if (!requestedModel) {
return res.status(HttpCode.FORBIDDEN).json({
error: {
message:
"This resource restricts access to specific models; a model must be specified"
}
});
}
const [matchedModel] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, provider.providerId),
eq(aiModels.modelKey, requestedModel)
)
)
.limit(1);
if (
!matchedModel ||
!allowedModelIds.includes(matchedModel.modelId)
) {
return res.status(HttpCode.FORBIDDEN).json({
error: {
message: `Model "${requestedModel}" is not permitted on this resource`
}
});
}
}
if (!provider.apiKey) {
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: { message: "AI provider has no API key configured" }
});
}
const secret = config.getRawConfig().server.secret!;
const apiKey = decrypt(provider.apiKey, secret);
const { upstreamUrl, authType } = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
if (!upstreamUrl) {
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: {
message: "AI provider has no upstream URL configured"
}
});
}
const targetUrl = `${upstreamUrl.replace(/\/$/, "")}${getCompletionsPath(
provider.type as AiProviderType
)}`;
const headers: Record<string, string> = {
"Content-Type": "application/json"
};
if (authType === "bearer") {
headers["Authorization"] = `Bearer ${apiKey}`;
}
// No dedicated per-request TLS agent is wired up (no extra deps for
// this v1 gateway) - toggle the process-wide Node TLS check instead.
// Known limitation: this is not safe under concurrent requests mixing
// skipTlsVerification providers with strict ones.
const restoreTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
if (provider.skipTlsVerification) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
let upstreamRes: globalThis.Response;
try {
upstreamRes = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(req.body)
});
} finally {
if (provider.skipTlsVerification) {
if (restoreTlsReject === undefined) {
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
} else {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = restoreTlsReject;
}
}
}
const contentType = upstreamRes.headers.get("content-type") || "";
const isStream =
req.body?.stream === true ||
contentType.includes("text/event-stream");
res.status(upstreamRes.status);
res.setHeader("Content-Type", contentType || "application/json");
if (isStream && upstreamRes.body) {
res.flushHeaders();
const reader = upstreamRes.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
return res.end();
}
const text = await upstreamRes.text();
return res.send(text);
} catch (error) {
logger.error(error);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: { message: "Failed to proxy inference request" }
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./chatCompletions";
+8
View File
@@ -3,6 +3,7 @@ import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as aiGateway from "@server/routers/aiGateway";
import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp";
@@ -63,3 +64,10 @@ internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
// AI inference gateway - minimal chat-completions proxy for inference-mode
// resources/siteResources
internalRouter.post(
"/ai-gateway/chat/completions",
aiGateway.chatCompletions
);
+17 -4
View File
@@ -90,11 +90,22 @@ const createHttpResourceSchema = z
domainId: z.string(),
stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional(),
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
mode: z
.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp", "inference"])
.optional(),
// SSH Settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
// Inference settings
aiProviderId: z
.number()
.int()
.positive()
.optional()
.describe(
"For inference-mode resources: the AI provider this resource proxies chat completions to."
)
})
.refine(
(data) => {
@@ -365,7 +376,8 @@ async function createHttpResource(
mode,
authDaemonPort,
authDaemonMode,
pamMode
pamMode,
aiProviderId
} = parsedBody.data;
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
@@ -552,7 +564,8 @@ async function createHttpResource(
postAuthPath: postAuthPath,
wildcard,
health: "unknown",
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
defaultResourcePolicyId: defaultPolicy.resourcePolicyId,
aiProviderId: aiProviderId ?? null
})
.returning();
@@ -120,6 +120,15 @@ const updateHttpResourceBodySchema = z
.optional()
.describe(
"ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings."
),
aiProviderId: z
.number()
.int()
.positive()
.nullable()
.optional()
.describe(
"For inference-mode resources: the AI provider this resource proxies chat completions to. Set to null to unlink."
)
})
.refine((data) => Object.keys(data).length > 0, {
@@ -78,7 +78,15 @@ const createSiteResourceSchema = z
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
aiProviderId: z
.number()
.int()
.positive()
.optional()
.describe(
"For inference-mode site resources: the AI provider this resource proxies chat completions to."
)
})
.strict()
.refine(
@@ -322,7 +330,8 @@ export async function createSiteResource(
authDaemonMode,
pamMode,
domainId,
subdomain
subdomain,
aiProviderId
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
@@ -594,7 +603,8 @@ export async function createSiteResource(
domainId,
subdomain: finalSubdomain,
fullDomain,
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this
aiProviderId: aiProviderId ?? null
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
@@ -78,7 +78,16 @@ const updateSiteResourceSchema = z
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: z.string().optional(),
subdomain: z.string().optional()
subdomain: z.string().optional(),
aiProviderId: z
.number()
.int()
.positive()
.nullable()
.optional()
.describe(
"For inference-mode site resources: the AI provider this resource proxies chat completions to. Set to null to unlink."
)
})
.strict()
.refine(
@@ -329,7 +338,8 @@ export async function updateSiteResource(
authDaemonMode,
pamMode,
domainId,
subdomain
subdomain,
aiProviderId
} = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
@@ -594,6 +604,7 @@ export async function updateSiteResource(
networkId: mode === "inference" ? null : undefined,
requiresExitNodeConnection:
mode !== undefined ? mode === "inference" : undefined,
aiProviderId: aiProviderId,
...sshPamSet
})
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
@@ -20,6 +20,9 @@ export async function traefikConfigProvider(
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost = config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl = `http://${maintenanceHost}:${
config.getRawConfig().server.internal_port
}/api/v1/ai-gateway`;
const traefikConfig = await getTraefikConfig(
currentExitNodeId,
@@ -28,7 +31,8 @@ export async function traefikConfigProvider(
build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources,
pangolinUIUrl,
pangolinUIUrl
pangolinUIUrl,
aiGatewayUrl
);
if (traefikConfig?.http?.middlewares) {