Compare commits

...

34 Commits

Author SHA1 Message Date
Owen 1c9ede9729 Pull the session cookie properly 2026-08-02 12:12:48 -04:00
Owen 5daf334e32 Endpoints to update models on the resource 2026-08-02 10:50:50 -04:00
Owen cb421f5c41 first pass of traefik -> basic gateway 2026-08-02 10:16:36 -04:00
miloschwartz 28430dde74 add targets and refactor endpoints 2026-07-31 17:58:23 -04:00
Owen 573747c237 Add basic page to configure the alias 2026-07-31 17:47:45 -04:00
Owen 9e10428640 Handle the alias create and update special case exit nodes 2026-07-31 17:22:08 -04:00
Owen 104dcebda7 Fix types 2026-07-31 16:50:35 -04:00
Owen 7e05c16ccd Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-07-31 16:30:30 -04:00
Owen a6b97ea130 Handle aliases when registering 2026-07-31 16:21:55 -04:00
miloschwartz 1333c8e841 add default providers and support overrides 2026-07-31 15:55:10 -04:00
miloschwartz f1a2da277e add crud for providers and models 2026-07-31 15:44:17 -04:00
miloschwartz 3d34ed70b0 add basic schema 2026-07-31 15:23:19 -04:00
Owen df7bb2fb57 Get exit node every time 2026-07-31 15:17:16 -04:00
Owen 4d7a4c809d sync the exit node connection 2026-07-31 14:23:51 -04:00
Owen 82d1d29844 Update site resources to handle new inference 2026-07-31 14:23:51 -04:00
Owen 117df40e68 send if we need to connect to the gerbil or not 2026-07-31 14:23:51 -04:00
Owen 2e8576bdb0 Add inference resource type 2026-07-31 14:23:51 -04:00
Owen 2584473898 Handle the ping exit node request backward mode for olm and install handler 2026-07-31 14:23:51 -04:00
Owen 0d56503ace Fix postgres schema exit node subnet 2026-07-31 14:23:51 -04:00
Owen 972e16d0c5 Rename subnet for clarity, pick subnet on client 2026-07-31 14:23:51 -04:00
Owen c535abf832 Add exit node selection to the clients 2026-07-31 14:23:51 -04:00
Owen 59ea701304 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-31 14:23:43 -04:00
miloschwartz 7d7c54107d add first seen and last seen columns to user devices table 2026-07-31 14:23:06 -04:00
Owen Schwartz f0f6673d69 Merge pull request #3511 from fosrl/feat/show-idp-icon-in-smart-login-form
feat: show the correct idp type icon in the last used idp
2026-07-31 09:32:01 -04:00
Owen Schwartz 71561d0e65 Merge pull request #3512 from fosrl/fix/batch-status-hover-data
Fix: resolve batch certificates for NS & Wildcard domains
2026-07-31 09:31:21 -04:00
Fred KISSIE af87edf3a6 ♻️ use const 2026-07-30 22:06:38 +01:00
Fred KISSIE 13caad18c7 🎨 format file 2026-07-30 21:53:46 +01:00
Fred KISSIE 47522b7e3a ♻️ set cert error if initial value is null 2026-07-30 21:53:31 +01:00
Fred KISSIE c8c8d74452 🐛 resolve certificates for non exact domains too 2026-07-30 21:52:51 +01:00
Fred KISSIE e7098963d6 ♻️ Code Refactoring 2026-07-30 17:56:15 +01:00
Fred KISSIE f015fb592b ♻️ Prioritize variant in place of idp.type if available 2026-07-30 17:55:58 +01:00
Fred KISSIE c099167905 Merge branch 'dev' into feat/show-idp-icon-in-smart-login-form 2026-07-30 17:37:47 +01:00
Owen b0e274f5a9 Fix 100% showing on resources with no hc anymore 2026-07-30 11:14:34 -04:00
Fred KISSIE e0a8721207 🚧 wip: idp type icon in last used idp 2026-07-29 22:29:56 +01:00
114 changed files with 6247 additions and 604 deletions
+12
View File
@@ -1424,6 +1424,16 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionCreateAiProvider": "Create AI Provider",
"actionDeleteAiProvider": "Delete AI Provider",
"actionGetAiProvider": "Get AI Provider",
"actionListAiProviders": "List AI Providers",
"actionUpdateAiProvider": "Update AI Provider",
"actionCreateAiModel": "Create AI Model",
"actionDeleteAiModel": "Delete AI Model",
"actionGetAiModel": "Get AI Model",
"actionListAiModels": "List AI Models",
"actionUpdateAiModel": "Update AI Model",
"actionApplyBlueprint": "Apply Blueprint", "actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints", "actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint", "actionGetBlueprint": "Get Blueprint",
@@ -2246,6 +2256,7 @@
"requireDeviceApproval": "Require Device Approvals", "requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings", "sshSettings": "SSH Settings",
"inferenceSettings": "Inference Settings",
"sshAccess": "SSH Access", "sshAccess": "SSH Access",
"rdpSettings": "RDP Settings", "rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings", "vncSettings": "VNC Settings",
@@ -2401,6 +2412,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeInference": "Inference",
"editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme", "editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS", "editInternalResourceDialogEnableSsl": "Enable TLS",
+13 -1
View File
@@ -50,6 +50,8 @@ export enum ActionsEnum {
setResourceUsers = "setResourceUsers", setResourceUsers = "setResourceUsers",
setResourceRoles = "setResourceRoles", setResourceRoles = "setResourceRoles",
listResourceUsers = "listResourceUsers", listResourceUsers = "listResourceUsers",
listResourceAiModels = "listResourceAiModels",
setResourceAiModels = "setResourceAiModels",
// removeRoleSite = "removeRoleSite", // removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction", // addRoleAction = "addRoleAction",
// removeRoleAction = "removeRoleAction", // removeRoleAction = "removeRoleAction",
@@ -182,7 +184,17 @@ export enum ActionsEnum {
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist", setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules", setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView" createOrgWideLauncherView = "createOrgWideLauncherView",
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
createAiModel = "createAiModel",
deleteAiModel = "deleteAiModel",
getAiModel = "getAiModel",
listAiModels = "listAiModels",
updateAiModel = "updateAiModel"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(
+121 -9
View File
@@ -99,6 +99,7 @@ export const sites = pgTable(
name: varchar("name").notNull(), name: varchar("name").notNull(),
pubKey: varchar("pubKey"), pubKey: varchar("pubKey"),
subnet: varchar("subnet"), subnet: varchar("subnet"),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: real("bytesIn").default(0), megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0), megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -194,7 +195,12 @@ export const resources = pgTable(
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false), wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc mode: text("mode")
.default("http")
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(),
pamMode: varchar("pamMode", { length: 32 }) pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
.default("passthrough"), .default("passthrough"),
@@ -204,7 +210,11 @@ export const resources = pgTable(
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
status: varchar("status") status: varchar("status")
.$type<"pending" | "approved">() .$type<"pending" | "approved">()
.default("approved") .default("approved"),
aiProviderId: integer("aiProviderId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
)
}, },
(t) => [ (t) => [
index("idx_resources_fulldomain") index("idx_resources_fulldomain")
@@ -215,6 +225,19 @@ export const resources = pgTable(
] ]
); );
export const resourceAiModels = pgTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = pgTable("labels", { export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(), labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
@@ -317,11 +340,18 @@ export const targets = pgTable(
"targets", "targets",
{ {
targetId: serial("targetId").primaryKey(), targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId") resourceId: integer("resourceId").references(
.references(() => resources.resourceId, { () => resources.resourceId,
{
onDelete: "cascade" onDelete: "cascade"
}) }
.notNull(), ),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{
onDelete: "cascade"
}
),
siteId: integer("siteId") siteId: integer("siteId")
.references(() => sites.siteId, { .references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
@@ -345,6 +375,7 @@ export const targets = pgTable(
}, },
(t) => [ (t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId), index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource") index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId) .on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`) .where(sql`${t.enabled} = true`)
@@ -424,11 +455,14 @@ export const siteResources = pgTable(
onDelete: "restrict" onDelete: "restrict"
} }
), ),
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
.notNull()
.default(false),
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode") mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh">() .$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http" .notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
@@ -458,11 +492,30 @@ export const siteResources = pgTable(
fullDomain: varchar("fullDomain"), fullDomain: varchar("fullDomain"),
status: varchar("status") status: varchar("status")
.$type<"pending" | "approved">() .$type<"pending" | "approved">()
.default("approved") .default("approved"),
aiProviderId: integer("aiProviderId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
)
}, },
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
); );
export const siteResourceAiModels = pgTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = pgTable( export const networks = pgTable(
"networks", "networks",
{ {
@@ -1181,7 +1234,7 @@ export const clients = pgTable(
olmId: text("olmId"), // to lock it to a specific olm optionally olmId: text("olmId"), // to lock it to a specific olm optionally
name: varchar("name").notNull(), name: varchar("name").notNull(),
pubKey: varchar("pubKey"), pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(), exitNodeSubnet: varchar("exitNodeSubnet").notNull(),
megabytesIn: real("bytesIn"), megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"), megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -1540,6 +1593,61 @@ export const statusHistory = pgTable(
] ]
); );
export const aiProviders = pgTable("aiProviders", {
providerId: serial("providerId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
type: varchar("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: varchar("apiKeyLastChars"),
authType: varchar("authType").$type<"bearer">(),
routingMode: varchar("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
skipTlsVerification: boolean("skipTlsVerification")
.notNull()
.default(false),
budgetAmount: real("budgetAmount"),
budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
});
export const aiModels = pgTable(
"aiModels",
{
modelId: serial("modelId").primaryKey(),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: varchar("modelKey").notNull(),
name: varchar("name").notNull(),
budgetAmount: real("budgetAmount"),
budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@@ -1624,3 +1732,7 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>; export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
+120 -10
View File
@@ -4,6 +4,7 @@ import {
index, index,
integer, integer,
primaryKey, primaryKey,
real,
sqliteTable, sqliteTable,
text, text,
unique unique
@@ -107,7 +108,7 @@ export const sites = sqliteTable("sites", {
}), }),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0), megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0), megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -203,7 +204,10 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode") pamMode: text("pamMode")
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
.default("passthrough"), .default("passthrough"),
@@ -211,9 +215,26 @@ export const resources = sqliteTable("resources", {
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved") status: text("status").$type<"pending" | "approved">().default("approved"),
aiProviderId: integer("aiProviderId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
)
}); });
export const resourceAiModels = sqliteTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }), labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
@@ -322,11 +343,12 @@ export const clientLabels = sqliteTable(
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId") resourceId: integer("resourceId").references(() => resources.resourceId, {
.references(() => resources.resourceId, { onDelete: "cascade"
onDelete: "cascade" }),
}) providerId: integer("providerId").references(() => aiProviders.providerId, {
.notNull(), onDelete: "cascade"
}),
siteId: integer("siteId") siteId: integer("siteId")
.references(() => sites.siteId, { .references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
@@ -422,10 +444,17 @@ export const siteResources = sqliteTable("siteResources", {
() => networks.networkId, () => networks.networkId,
{ onDelete: "restrict" } { onDelete: "restrict" }
), ),
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
mode: "boolean"
})
.notNull()
.default(false),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" mode: text("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
@@ -450,9 +479,28 @@ export const siteResources = sqliteTable("siteResources", {
}), }),
subdomain: text("subdomain"), subdomain: text("subdomain"),
fullDomain: text("fullDomain"), fullDomain: text("fullDomain"),
status: text("status").$type<"pending" | "approved">().default("approved") status: text("status").$type<"pending" | "approved">().default("approved"),
aiProviderId: integer("aiProviderId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
)
}); });
export const siteResourceAiModels = sqliteTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = sqliteTable("networks", { export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }), networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"), niceId: text("niceId"),
@@ -599,6 +647,7 @@ export const clients = sqliteTable("clients", {
pubKey: text("pubKey"), pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(), subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"), megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"), megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -1526,6 +1575,63 @@ export const statusHistory = sqliteTable(
] ]
); );
export const aiProviders = sqliteTable("aiProviders", {
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: text("apiKeyLastChars"),
authType: text("authType").$type<"bearer">(),
routingMode: text("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
.notNull()
.default(false),
budgetAmount: real("budgetAmount"),
budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
});
export const aiModels = sqliteTable(
"aiModels",
{
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: text("modelKey").notNull(),
name: text("name").notNull(),
budgetAmount: real("budgetAmount"),
budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@@ -1608,3 +1714,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>; >;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
+4
View File
@@ -9,6 +9,8 @@ import { createIntegrationApiServer } from "./integrationApiServer";
import { import {
ApiKey, ApiKey,
ApiKeyOrg, ApiKeyOrg,
AiModel,
AiProvider,
RemoteExitNode, RemoteExitNode,
Session, Session,
SiteResource, SiteResource,
@@ -83,6 +85,8 @@ declare global {
userOrgIds?: string[]; userOrgIds?: string[];
remoteExitNode?: RemoteExitNode; remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource; siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
orgPolicyAllowed?: boolean; orgPolicyAllowed?: boolean;
} }
} }
+106
View File
@@ -0,0 +1,106 @@
export type AiProviderType =
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom";
export type AiProviderAuthType = "bearer";
export type AiBudgetUnit = "usd" | "tokens";
export type AiProviderRoutingMode = "url" | "target";
type AiProviderDefaults = {
upstreamUrl: string | null;
authType: AiProviderAuthType;
};
export const AI_PROVIDER_DEFAULTS: Record<
Exclude<AiProviderType, "custom">,
AiProviderDefaults
> = {
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer"
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "bearer"
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
authType: "bearer"
},
vertexAi: {
upstreamUrl: null,
authType: "bearer"
},
bedrock: {
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
authType: "bearer"
},
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer"
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
authType: "bearer"
},
vercelAiGateway: {
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
authType: "bearer"
}
};
export function providerRequiresUpstreamUrl(
type: AiProviderType,
routingMode: AiProviderRoutingMode = "url"
): boolean {
if (routingMode === "target") {
return false;
}
if (type === "custom") {
return true;
}
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
}
export function resolveAiProviderConfig(input: {
type: AiProviderType;
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
routingMode: AiProviderRoutingMode;
} {
const routingMode = input.routingMode ?? "url";
if (routingMode === "target") {
return {
upstreamUrl: null,
authType: input.authType ?? "bearer",
routingMode
};
}
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl,
authType: input.authType,
routingMode
};
}
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
routingMode
};
}
@@ -202,6 +202,10 @@ async function handleResource(
return; return;
} }
if (!target.resourceId) {
return;
}
const [resource] = await trx const [resource] = await trx
.select() .select()
.from(resources) .from(resources)
@@ -227,9 +231,7 @@ async function handleResource(
let health = "healthy"; let health = "healthy";
const allUnknown = monitoredTargets.length === 0; const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every( const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
(t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every( const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy" (t) => t.hcHealth === "unhealthy"
); );
-14
View File
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
continue; continue;
} }
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet // Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } = const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx); await getNextAvailableClientSubnet(orgId, trx);
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,
orgId: userOrg.orgId, orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client", name: olm.name || "User Client",
subnet: updatedSubnet, subnet: updatedSubnet,
olmId: olm.olmId, olmId: olm.olmId,
+8 -1
View File
@@ -64,13 +64,20 @@ export async function performDeleteResources(
const targetsByResourceId = new Map<number, Target[]>(); const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) { for (const target of targetsToBeRemoved) {
if (target.resourceId == null) {
continue;
}
const existing = targetsByResourceId.get(target.resourceId) ?? []; const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target); existing.push(target);
targetsByResourceId.set(target.resourceId, existing); targetsByResourceId.set(target.resourceId, existing);
} }
const targetIdToResourceId = new Map( const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) targetsToBeRemoved.flatMap((target) =>
target.resourceId == null
? []
: [[target.targetId, target.resourceId] as const]
)
); );
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>(); const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
+5 -3
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { import {
db, db,
resources, resources,
@@ -33,9 +33,11 @@ export async function getResourceIdsForSite(
const rows = await trx const rows = await trx
.selectDistinct({ resourceId: targets.resourceId }) .selectDistinct({ resourceId: targets.resourceId })
.from(targets) .from(targets)
.where(eq(targets.siteId, siteId)); .where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
return rows.map((row) => row.resourceId); return rows
.map((row) => row.resourceId)
.filter((resourceId): resourceId is number => resourceId != null);
} }
export async function getSiteResourceIdsForSite( export async function getSiteResourceIdsForSite(
@@ -0,0 +1,40 @@
import { db, sites, clients } from "@server/db";
import { and, eq, count } from "drizzle-orm";
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
// higher = more desirable
// like saying, this node has x% of its capacity left
export async function calculateExitNodeWeight(
exitNodeId: number,
maxConnections: number | null | undefined
): Promise<number | null> {
if (maxConnections === null || maxConnections === undefined) {
return 1;
}
const [[siteConnections], [clientConnections]] = await Promise.all([
db
.select({ count: count() })
.from(sites)
.where(
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
),
db
.select({ count: count() })
.from(clients)
.where(
and(
eq(clients.exitNodeId, exitNodeId),
eq(clients.online, true)
)
)
]);
const currentConnections = siteConnections.count + clientConnections.count;
if (currentConnections >= maxConnections) {
return null;
}
return (maxConnections - currentConnections) / maxConnections;
}
+10 -1
View File
@@ -1,6 +1,5 @@
import { db, exitNodes, Transaction } from "@server/db"; import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export async function verifyExitNodeOrgAccess( export async function verifyExitNodeOrgAccess(
@@ -52,6 +51,16 @@ export async function listExitNodes(
return allExitNodes; return allExitNodes;
} }
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export function selectBestExitNode( export function selectBestExitNode(
pingResults: ExitNodePingResult[] pingResults: ExitNodePingResult[]
): ExitNodePingResult | null { ): ExitNodePingResult | null {
@@ -0,0 +1,41 @@
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getUniqueSubnetForExitNode(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const [sitesQuery, clientsQuery] = await Promise.all([
trx
.select({ subnet: sites.exitNodeSubnet })
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
trx
.select({ subnet: clients.exitNodeSubnet })
.from(clients)
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
]);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = [...sitesQuery, ...clientsQuery]
.map((row) => row.subnet)
.filter(
(subnet): subnet is string =>
!!subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from "./exitNodes";
export * from "./exitNodeComms"; export * from "./exitNodeComms";
export * from "./subnet"; export * from "./subnet";
export * from "./getCurrentExitNodeId"; export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight";
export * from "./getUniqueSubnetForExitNode";
+21 -8
View File
@@ -528,7 +528,10 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null }; export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(
allSiteResources: SiteResource[],
overrideIp?: string
): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter(
(sr) => (sr) =>
@@ -539,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
) )
.map((sr) => ({ .map((sr) => ({
alias: sr.alias || sr.fullDomain, alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress aliasAddress: overrideIp || sr.aliasAddress
})); }));
} }
@@ -660,9 +663,10 @@ export type CertRef = { id: string; cert: string; key: string };
* certificate (e.g. a wildcard cert used by thousands of site resources) * certificate (e.g. a wildcard cert used by thousands of site resources)
* only need that certificate sent once per sync message. * only need that certificate sent once per sync message.
*/ */
export function dedupeCertsForTargets( export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
targetsV2: SubnetProxyTargetV2[] targets: SubnetProxyTargetV2[];
): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } { certs: CertRef[];
} {
const idByContent = new Map<string, string>(); const idByContent = new Map<string, string>();
const certs: CertRef[] = []; const certs: CertRef[] = [];
@@ -674,7 +678,10 @@ export function dedupeCertsForTargets(
const contentKey = `${target.tlsCert}|${target.tlsKey}`; const contentKey = `${target.tlsCert}|${target.tlsKey}`;
let id = idByContent.get(contentKey); let id = idByContent.get(contentKey);
if (!id) { if (!id) {
id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16); id = createHash("sha1")
.update(contentKey)
.digest("hex")
.slice(0, 16);
idByContent.set(contentKey, id); idByContent.set(contentKey, id);
certs.push({ id, cert: target.tlsCert, key: target.tlsKey }); certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
} }
@@ -708,7 +715,9 @@ export async function batchFetchCertsForSiteResources(
): Promise<CertByDomain> { ): Promise<CertByDomain> {
const domains = new Set( const domains = new Set(
allSiteResources allSiteResources
.filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain) .filter(
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
)
.map((r) => r.fullDomain as string) .map((r) => r.fullDomain as string)
); );
@@ -852,7 +861,11 @@ export async function generateSubnetProxyTargetV2(
new Set([siteResource.fullDomain]), new Set([siteResource.fullDomain]),
true true
); );
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { if (
certs.length > 0 &&
certs[0].certFile &&
certs[0].keyFile
) {
tlsCert = certs[0].certFile; tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile; tlsKey = certs[0].keyFile;
} else { } else {
+296 -6
View File
@@ -19,7 +19,7 @@ import {
userOrgRoles, userOrgRoles,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm"; import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import { import {
@@ -27,6 +27,9 @@ import {
deletePeersBatch as olmDeletePeersBatch deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers"; } from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { sendToClientsBatch } from "#dynamic/routers/ws";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
generateAliasConfig, generateAliasConfig,
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
); );
if (sitesList.length === 0) { if (sitesList.length === 0 && siteResource.networkId !== null) {
// A site resource with a networkId is expected to have at least one
// site attached via siteNetworks. Resources with no networkId (e.g.
// inference-mode resources, which connect clients directly to the
// exit node instead of any site) are expected to have no sites, so
// don't warn for those.
logger.warn( logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
); );
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove, clientSiteResourcesToRemove,
trx trx
); );
// If this resource requires clients to be connected to the exit node
// (e.g. an inference resource), re-sync the connect/disconnect state for
// every client whose access to it may have changed - both those who
// currently have access and those who just lost it.
if (siteResource.requiresExitNodeConnection) {
await syncClientExitNodeConnections(
Array.from(
new Set([
...mergedAllClientIds,
...existingClientSiteResourceIds
])
),
trx
);
}
} }
async function handleMessagesForSiteClients( async function handleMessagesForSiteClients(
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.exitNodeSubnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
sourcePort: parsedEndpoint.port, sourcePort: parsedEndpoint.port,
destinations: [ destinations: [
{ {
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
] ]
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
} else { } else {
// add to the existing destinations // add to the existing destinations
destinations.destinations.push({ destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
@@ -1052,6 +1076,236 @@ export async function updateClientSiteDestinations(
} }
} }
// Determines, for each of the given clients, whether they currently have
// access to any enabled site resource with requiresExitNodeConnection set
// (e.g. an inference-mode resource) and tells the client's olm to connect to
// or disconnect from its assigned exit node accordingly. Site resources with
// requiresExitNodeConnection don't belong to any site/network, so this can't
// be derived from the per-site peer logic above - it has to be recomputed
// from the client's full current resource access every time that access
// changes.
async function syncClientExitNodeConnections(
clientIds: number[],
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
// Only clients with an exit node assigned can be told to connect/disconnect.
const clientsData = await trx
.select({
clientId: clients.clientId,
exitNodeId: clients.exitNodeId,
exitNodeSubnet: clients.exitNodeSubnet
})
.from(clients)
.where(
and(
inArray(clients.clientId, uniqueClientIds),
isNotNull(clients.exitNodeId)
)
);
if (clientsData.length === 0) {
return;
}
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
const requiresExitNodeRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
clientIdsWithExitNode
),
eq(siteResources.enabled, true),
eq(siteResources.requiresExitNodeConnection, true)
)
);
const needsConnectSet = new Set(
requiresExitNodeRows.map((r) => r.clientId)
);
const exitNodeIds = Array.from(
new Set(
clientsData
.map((c) => c.exitNodeId)
.filter((id): id is number => id !== null)
)
);
const exitNodeRows =
exitNodeIds.length > 0
? await trx
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
: [];
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, clientIdsWithExitNode));
const olmByClientId = new Map(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r])
);
const relayPort = config.getRawConfig().gerbil.clients_start_port;
const connectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean };
}[] = [];
const disconnectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean };
}[] = [];
for (const client of clientsData) {
const olm = olmByClientId.get(client.clientId);
if (!olm) {
// No olm registered for this client yet/anymore, nothing to send.
continue;
}
const needsConnect = needsConnectSet.has(client.clientId);
if (needsConnect) {
const exitNode = client.exitNodeId
? exitNodeById.get(client.exitNodeId)
: undefined;
if (!exitNode || !client.exitNodeSubnet) {
logger.warn(
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
);
continue;
}
connectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/connect",
data: {
connect: true,
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0]
}
},
options: { compress: canCompress(olm.version, "olm") }
});
} else {
disconnectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/disconnect",
data: {}
},
options: { compress: canCompress(olm.version, "olm") }
});
}
}
if (connectPayloads.length > 0) {
await sendToClientsBatch(connectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node connect messages:`,
error
);
});
}
if (disconnectPayloads.length > 0) {
await sendToClientsBatch(disconnectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
error
);
});
}
}
// Notifies the olms of every given client that the alias of the site resource
// they're using an exit node connection for has changed, via the dedicated
// exit node data-update message. Unlike syncClientExitNodeConnections, this
// doesn't touch connect/disconnect state - it's purely a rename for clients
// that are (and remain) connected to the exit node for this resource.
async function syncClientExitNodeAliasUpdate(
clientIds: number[],
oldAlias: string | null,
newAlias: string | null,
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
const oldAliases = oldAlias ? [oldAlias] : [];
const newAliases = newAlias ? [newAlias] : [];
if (oldAliases.length === 0 && newAliases.length === 0) {
return;
}
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, uniqueClientIds));
const updatePayloads = olmRows
.filter((r) => r.clientId !== null)
.map((olm) => ({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/data/update",
data: {
oldAliases,
newAliases
}
},
options: { compress: canCompress(olm.version, "olm") }
}));
if (updatePayloads.length > 0) {
await sendToClientsBatch(updatePayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node alias update messages:`,
error
);
});
}
}
async function handleSubnetProxyTargetUpdates( async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource, siteResource: SiteResource,
sitesList: Site[], sitesList: Site[],
@@ -1282,7 +1536,7 @@ export async function handleMessagingForUpdatedSiteResource(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
); );
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED // !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
const mergedAllClients = await trx const mergedAllClients = await trx
.select({ .select({
clientId: clientSiteResourcesAssociationsCache.clientId, clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -1709,6 +1963,38 @@ export async function handleMessagingForUpdatedSiteResource(
); );
} }
// For a resource that stays on an exit node connection across the update,
// the alias is the only field that affects already-connected clients (the
// exit node itself, its endpoint, etc. are not per-resource). Tell those
// clients' olms about the rename directly via the exit node data-update
// message rather than a full connect/disconnect cycle.
if (
existingSiteResource?.requiresExitNodeConnection &&
updatedSiteResource.requiresExitNodeConnection &&
aliasChanged
) {
await syncClientExitNodeAliasUpdate(
mergedAllClients.map((c) => c.clientId),
existingSiteResource.alias,
updatedSiteResource.alias,
trx
);
}
// If this resource requires (or required) clients to be connected to the
// exit node (e.g. an inference resource), re-sync connect/disconnect
// state for every client currently associated with it - covers toggling
// requiresExitNodeConnection on update as well as enabling/disabling it.
if (
updatedSiteResource.requiresExitNodeConnection ||
existingSiteResource?.requiresExitNodeConnection
) {
await syncClientExitNodeConnections(
mergedAllClients.map((c) => c.clientId),
trx
);
}
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
); );
@@ -1990,6 +2276,10 @@ async function rebuildClientAssociationsFromClientImpl(
resourcesToRemove, resourcesToRemove,
trx trx
); );
// Re-sync exit node connect/disconnect state based on this client's
// current full set of resource access (e.g. inference resources).
await syncClientExitNodeConnections([client.clientId], trx);
} }
async function handleMessagesForClientSites( async function handleMessagesForClientSites(
+5 -1
View File
@@ -516,6 +516,9 @@ export class TraefikConfigManager {
const maintenanceHost = const maintenanceHost =
config.getRawConfig().server.internal_hostname; config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl = `http://${maintenanceHost}:${
config.getRawConfig().server.internal_port
}/api/v1/ai-gateway`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig( traefikConfig = await getTraefikConfig(
@@ -528,7 +531,8 @@ export class TraefikConfigManager {
? false ? false
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
pangolinUIUrl, // generate maintenance pages on cloud and hybrid pangolinUIUrl, // generate maintenance pages on cloud and hybrid
pangolinUIUrl // generate browser gateway targets on cloud and hybrid pangolinUIUrl, // generate browser gateway targets on cloud and hybrid
aiGatewayUrl
); );
const domains = new Set<string>(); const domains = new Set<string>();
+119 -4
View File
@@ -1,4 +1,4 @@
import { db, targetHealthCheck, domains } from "@server/db"; import { db, targetHealthCheck, domains, aiProviders } from "@server/db";
import { import {
and, and,
eq, eq,
@@ -45,7 +45,8 @@ export async function getTraefikConfig(
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true, allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
aiGatewayUrl: string | null = null
): Promise<any> { ): Promise<any> {
// Get resources with their targets and sites in a single optimized query // Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources // Start from sites on this exit node, then join to targets and resources
@@ -87,7 +88,7 @@ export async function getTraefikConfig(
siteId: sites.siteId, siteId: sites.siteId,
siteType: sites.type, siteType: sites.type,
siteOnline: sites.online, siteOnline: sites.online,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId,
// Domain cert resolver fields // Domain cert resolver fields
domainCertResolver: domains.certResolver, domainCertResolver: domains.certResolver,
@@ -209,8 +210,37 @@ export async function getTraefikConfig(
}); });
}); });
// Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites
// join above - query them separately and include them on every exit node.
const inferenceResources = await db
.select({
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
.innerJoin(
aiProviders,
eq(resources.aiProviderId, aiProviders.providerId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true),
eq(aiProviders.enabled, true)
)
);
// make sure we have at least one resource // make sure we have at least one resource
if (resourcesMap.size === 0) { if (resourcesMap.size === 0 && inferenceResources.length === 0) {
return {}; return {};
} }
@@ -673,5 +703,90 @@ export async function getTraefikConfig(
}; };
} }
} }
if (aiGatewayUrl) {
for (const ir of inferenceResources) {
if (!ir.enabled) continue;
if (!ir.domainId || !ir.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
const rule = `Host(\`${fullDomain}\`)`;
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
const tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 100,
...(ir.ssl ? { tls } : {})
};
if (ir.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }],
passHostHeader: true
}
};
}
}
return config_output; return config_output;
} }
+2
View File
@@ -27,6 +27,8 @@ export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess"; export * from "./verifyApiKeyAccess";
export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifySiteProvisioningKeyAccess";
export * from "./verifyDomainAccess"; export * from "./verifyDomainAccess";
export * from "./verifyAiProviderAccess";
export * from "./verifyAiModelAccess";
export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserIsOrgOwner";
export * from "./verifyUserFromResourceSession"; export * from "./verifyUserFromResourceSession";
export * from "./verifySiteResourceAccess"; export * from "./verifySiteResourceAccess";
+2
View File
@@ -16,5 +16,7 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyAiProviderAccess";
export * from "./verifyApiKeyAiModelAccess";
export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeyResourcePolicyAccess";
export * from "./verifyApiKeySiteProvisioningKeyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess";
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
}
const orgId = row.provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = provider;
return next();
}
const orgId = provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, targets, apiKeyOrg } from "@server/db"; import { aiProviders, resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess(
); );
} }
const resourceId = target.resourceId; const { resourceId, providerId } = target;
if (!resourceId) { if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID` `Target with ID ${targetId} has invalid ownership`
)
);
}
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
) )
); );
} }
if (apiKey.isRoot) { if (apiKey.isRoot) {
// Root keys can access any key in any org // Root keys can access any target
return next(); return next();
} }
if (!resource.orgId) { let orgId: string;
return next( if (resourceId) {
createHttpError( const [resource] = await db
HttpCode.INTERNAL_SERVER_ERROR, .select()
`Resource with ID ${resourceId} does not have an organization ID` .from(resources)
) .where(eq(resources.resourceId, resourceId))
); .limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg) {
@@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess(
.where( .where(
and( and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, resource.orgId) eq(apiKeyOrg.orgId, orgId)
) )
) )
.limit(1); .limit(1);
@@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess(
} }
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
+107
View File
@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
const orgId = row.provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const orgId = provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
+71 -56
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, targets, userOrgs } from "@server/db"; import { aiProviders, resources, targets, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -25,9 +25,7 @@ export async function verifyTargetAccess(
} }
if (isNaN(targetId)) { if (isNaN(targetId)) {
return next( return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID"));
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
} }
const target = await db const target = await db
@@ -45,73 +43,88 @@ export async function verifyTargetAccess(
); );
} }
const resourceId = target[0].resourceId; const { resourceId, providerId } = target[0];
if (!resourceId) { if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
try { try {
const resource = await db let orgId: string;
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId!))
.limit(1);
if (resource.length === 0) { if (resourceId) {
return next( const [resource] = await db
createHttpError( .select()
HttpCode.NOT_FOUND, .from(resources)
`Resource with ID ${resourceId} not found` .where(eq(resources.resourceId, resourceId))
) .limit(1);
);
}
if (!resource[0].orgId) { if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.NOT_FOUND,
`resource with ID ${resourceId} does not have an organization ID` `Resource with ID ${resourceId} not found`
) )
); );
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
} }
if (!req.userOrg) { if (!req.userOrg) {
const res = await db const userOrgResult = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource[0].orgId)
)
); );
req.userOrg = res[0]; req.userOrg = userOrgResult[0];
} }
if (!req.userOrg) { if (!req.userOrg || req.userOrg.orgId !== orgId) {
next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization"
) )
); );
} else {
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!;
} }
const orgId = req.userOrg.orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId;
if (req.orgPolicyAllowed === undefined && orgId) { if (req.orgPolicyAllowed === undefined) {
const policyCheck = await checkOrgAccessPolicy({ const policyCheck = await checkOrgAccessPolicy({
orgId, orgId,
userId, userId,
@@ -128,22 +141,24 @@ export async function verifyTargetAccess(
} }
} }
const resourceAllowed = await canUserAccessResource({ if (resourceId) {
userId, const resourceAllowed = await canUserAccessResource({
resourceId, userId,
roleIds: req.userOrgRoleIds ?? [] resourceId,
}); roleIds: req.userOrgRoleIds ?? []
});
if (!resourceAllowed) { if (!resourceAllowed) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this resource" "User does not have access to this resource"
) )
); );
}
} }
next(); return next();
} catch (e) { } catch (e) {
return next( return next(
createHttpError( createHttpError(
+3 -1
View File
@@ -28,7 +28,9 @@ export enum OpenAPITags {
HealthCheck = "Health Check", HealthCheck = "Health Check",
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
PublicResourceLegacy = "Public Resource (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)",
PrivateResourceLegacy = "Private Resource (Legacy)" PrivateResourceLegacy = "Private Resource (Legacy)",
AiProvider = "AI Provider",
AiModel = "AI Model"
} }
// Order here controls the order tags are displayed in Swagger UI // Order here controls the order tags are displayed in Swagger UI
+10 -1
View File
@@ -25,7 +25,6 @@ import {
Transaction Transaction
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
import axios from "axios"; import axios from "axios";
import config from "../config"; import config from "../config";
@@ -330,6 +329,16 @@ export async function listExitNodes(
return exitNodesList; return exitNodesList;
} }
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
/** /**
* Selects the most suitable exit node from a list of ping results. * Selects the most suitable exit node from a list of ping results.
* *
+270 -4
View File
@@ -18,6 +18,7 @@ import {
domains, domains,
exitNodes, exitNodes,
loginPage, loginPage,
SiteResource,
targetHealthCheck targetHealthCheck
} from "@server/db"; } from "@server/db";
import { import {
@@ -40,7 +41,8 @@ import {
siteNetworks, siteNetworks,
siteResources, siteResources,
Target, Target,
targets targets,
aiProviders
} from "@server/db"; } from "@server/db";
import { import {
sanitize, sanitize,
@@ -85,7 +87,8 @@ export async function getTraefikConfig(
generateLoginPageRouters = false, generateLoginPageRouters = false,
allowRawResources = true, allowRawResources = true,
maintenancePageUiUrl: string | null = null, maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null
): Promise<any> { ): Promise<any> {
// Get resources with their targets and sites in a single optimized query // Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources // Start from sites on this exit node, then join to targets and resources
@@ -134,7 +137,7 @@ export async function getTraefikConfig(
siteId: sites.siteId, siteId: sites.siteId,
siteType: sites.type, siteType: sites.type,
siteOnline: sites.online, siteOnline: sites.online,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId,
// Namespace // Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId, domainNamespaceId: domainNamespaces.domainNamespaceId,
@@ -359,7 +362,7 @@ export async function getTraefikConfig(
let siteResourcesWithFullDomain: { let siteResourcesWithFullDomain: {
siteResourceId: number; siteResourceId: number;
fullDomain: string | null; fullDomain: string | null;
mode: "http" | "host" | "cidr" | "ssh"; mode: SiteResource["mode"];
}[] = []; }[] = [];
if ( if (
build == "enterprise" && build == "enterprise" &&
@@ -392,6 +395,65 @@ export async function getTraefikConfig(
); );
} }
// Inference-mode resources/siteResources have no targets/sites/network
// (their "backend" is the central AI gateway, not something on a site),
// so they can't be reached via the joins above - query them separately
// and include them on every exit node.
const inferenceResources = await db
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
.innerJoin(
aiProviders,
eq(resources.aiProviderId, aiProviders.providerId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true),
eq(aiProviders.enabled, true)
)
);
let siteResourcesInference: {
siteResourceId: number;
alias: string | null;
ssl: boolean | null;
enabled: boolean | null;
}[] = [];
if (build == "enterprise") {
siteResourcesInference = await db
.select({
siteResourceId: siteResources.siteResourceId,
alias: siteResources.alias,
ssl: siteResources.ssl,
enabled: siteResources.enabled
})
.from(siteResources)
.innerJoin(
aiProviders,
eq(siteResources.aiProviderId, aiProviders.providerId)
)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
eq(aiProviders.enabled, true),
isNotNull(siteResources.alias)
)
);
}
let validCerts: CertificateResult[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for // create a list of all domains to get certs for
@@ -413,6 +475,17 @@ export async function getTraefikConfig(
domains.add(bgResource.fullDomain); domains.add(bgResource.fullDomain);
} }
} }
// Include inference resource/siteResource domains
for (const ir of inferenceResources) {
if (ir.enabled && ir.ssl && ir.fullDomain) {
domains.add(ir.fullDomain);
}
}
for (const sr of siteResourcesInference) {
if (sr.enabled && sr.ssl && sr.alias) {
domains.add(sr.alias);
}
}
// get the valid certs for these domains // get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -1444,6 +1517,199 @@ export async function getTraefikConfig(
} }
} }
if (aiGatewayUrl) {
// Public inference resources: same TLS/cert-resolver handling as
// plain http-mode resources, but the service points at the AI
// gateway instead of any real backend targets.
for (const ir of inferenceResources) {
if (!ir.enabled) continue;
if (!ir.domainId || !ir.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
let rule: string;
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for inference resource domain: ${fullDomain}`
);
continue;
}
}
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
if (ir.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority: 100,
...(ir.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }],
passHostHeader: true
}
};
}
// Private (siteResource) inference resources: routed by their alias
// instead of a public fullDomain, and deliberately WITHOUT the
// badger middleware - no per-user auth/policy stack exists for
// siteResources today (see plan doc), so gating here is
// reachability-only for now.
for (const sr of siteResourcesInference) {
if (!sr.enabled || !sr.alias) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const alias = sr.alias;
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${alias}\`)`;
let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = alias.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === alias
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for inference siteResource alias: ${alias}`
);
continue;
}
}
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
if (sr.ssl) {
config_output.http.routers[routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: additionalMiddlewares,
service: serviceName,
rule,
priority: 100,
...(sr.ssl ? { tls } : {})
};
config_output.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: `${aiGatewayUrl}/chat/completions` }],
passHostHeader: true
}
};
}
}
if (generateLoginPageRouters) { if (generateLoginPageRouters) {
const exitNodeLoginPages = await db const exitNodeLoginPages = await db
.select({ .select({
@@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({
async function query(orgId: string, domainList: string[]) { async function query(orgId: string, domainList: string[]) {
// Try to get CNAME certificates first // Try to get CNAME certificates first
let existingCertificates = await db const existingCertificates = await db
.select({ .select({
certId: certificates.certId, certId: certificates.certId,
domain: certificates.domain, domain: certificates.domain,
@@ -73,16 +73,19 @@ async function query(orgId: string, domainList: string[]) {
.where(and(inArray(certificates.domain, domainList))); .where(and(inArray(certificates.domain, domainList)));
// All non resolved domain certificates might be `ns` or `wildcard`, // All non resolved domain certificates might be `ns` or `wildcard`,
// which means exact domain certificates do not // which means exact domain certificates do not exist
const nonAvailableCertificates = existingCertificates const foundDomains = new Set(
.filter((cert) => !domainList.includes(cert.domain)) existingCertificates.map((cert) => cert.domain)
.map((cert) => cert.domain); );
const domainsWithMissingCertificates = domainList.filter(
(domain) => !foundDomains.has(domain)
);
if (nonAvailableCertificates.length > 0) { if (domainsWithMissingCertificates.length > 0) {
const domainLevelDownSet = new Set<string>(); const domainLevelDownSet = new Set<string>();
const wildcardDomainSet = new Set<string>(); const wildcardDomainSet = new Set<string>();
for (const domain of nonAvailableCertificates) { for (const domain of domainsWithMissingCertificates) {
const domainLevelDown = domain.split(".").slice(1).join("."); const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`; const wildcardPrefixed = `*.${domainLevelDown}`;
domainLevelDownSet.add(domainLevelDown); domainLevelDownSet.add(domainLevelDown);
@@ -131,6 +134,7 @@ async function query(orgId: string, domainList: string[]) {
for (const domain of domainList) { for (const domain of domainList) {
const domainLevelDown = domain.split(".").slice(1).join("."); const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`; const wildcardPrefixed = `*.${domainLevelDown}`;
certificateMap[domain] = certificateMap[domain] =
existingCertificates.find( existingCertificates.find(
(cert) => (cert) =>
+3 -1
View File
@@ -351,6 +351,7 @@ hybridRouter.get(
} }
const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there
const aiGatewayUrl = `${config.getRawConfig().app.dashboard_url}/api/v1/ai-gateway`;
try { try {
const traefikConfig = await getTraefikConfig( const traefikConfig = await getTraefikConfig(
@@ -360,7 +361,8 @@ hybridRouter.get(
false, // Dont include login pages, false, // Dont include login pages,
true, // allow raw resources true, // allow raw resources
pangolinUIUrl, // dont generate maintenance page pangolinUIUrl, // dont generate maintenance page
pangolinUIUrl // generate browser gateway targets pangolinUIUrl, // generate browser gateway targets
aiGatewayUrl
); );
return response(res, { return response(res, {
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
); );
} }
if (site.exitNodeId && site.subnet) { if (site.exitNodeId && site.exitNodeSubnet) {
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
await addPeer(site.exitNodeId, { await addPeer(site.exitNodeId, {
publicKey: pubKey, publicKey: pubKey,
+379
View File
@@ -0,0 +1,379 @@
import { Request, Response } from "express";
import { and, eq } from "drizzle-orm";
import {
AiProvider,
aiModels,
aiProviders,
db,
resourceAiModels,
resources,
siteResourceAiModels,
siteResources,
users
} 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 { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import {
SESSION_COOKIE_NAME,
validateSessionToken
} from "@server/auth/sessions/app";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
type ResolvedTarget = {
resourceId: number | null;
orgId: string | null;
provider: AiProvider;
// null = no restriction; every enabled model on the provider is allowed
allowedModelIds: number[] | null;
};
// Fallback for clients that hit the endpoint directly (e.g. an AI tool's
// "API key" field) instead of going through a browser session - badger
// forwards whatever Authorization header the client sent untouched in that
// case. This prefix lets us tell "this bearer value is a Pangolin resource
// access token" apart from an arbitrary/opaque API key a user might paste
// in, without guessing based on format alone.
const USER_TOKEN_PREFIX = "pu_";
export type RequestUser = {
userId: string;
username: string;
email: string | null;
name: string | null;
role: string | null;
};
async function buildRequestUser(
userId: string,
orgId: string | null
): Promise<RequestUser | null> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return null;
}
const orgRoles = orgId ? await getUserOrgRoles(user.userId, orgId) : [];
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: orgRoles.map((r) => r.roleName).join(", ") || null
};
}
async function resolveRequestUser(
req: Request,
resourceId: number | null,
orgId: string | null
): Promise<RequestUser | null> {
// Public resources behind badger: badger passes the resource session
// cookie through to the backend (same mechanism the browser gateway,
// e.g. the SSH page, relies on), so we can validate it exactly like
// verifySessionUserMiddleware does for the dashboard.
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
if (sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
return buildRequestUser(user.userId, orgId);
}
}
// User devices hitting the endpoint directly (no browser session to
// forward) fall back to a Pangolin resource access token passed as the
// client's "API key".
const authHeader = req.headers["authorization"];
if (typeof authHeader !== "string" || !resourceId) {
return null;
}
const bearer = authHeader.match(/^Bearer\s+(.+)$/i)?.[1];
if (!bearer || !bearer.startsWith(USER_TOKEN_PREFIX)) {
return null;
}
const [accessTokenId, accessToken] = bearer
.slice(USER_TOKEN_PREFIX.length)
.split(".");
if (!accessTokenId || !accessToken) {
return null;
}
const { valid, tokenItem } = await verifyResourceAccessToken({
accessToken,
accessTokenId,
resourceId
});
if (!valid || !tokenItem?.userId) {
return null;
}
return buildRequestUser(tokenItem.userId, tokenItem.orgId);
}
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
const [resourceRow] = await db
.select({
resourceId: resources.resourceId,
orgId: resources.orgId,
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 {
resourceId: resourceRow.resourceId,
orgId: resourceRow.orgId,
provider: resourceRow.provider,
allowedModelIds: restrictions.length
? restrictions.map((r) => r.modelId)
: null
};
}
const [siteResourceRow] = await db
.select({
siteResourceId: siteResources.siteResourceId,
orgId: siteResources.orgId,
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 {
// siteResources have no per-user auth/policy stack today (see
// the routing comment in getTraefikConfig.ts), so there's no
// resource access token scope to validate a user token against.
resourceId: null,
orgId: siteResourceRow.orgId,
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, resourceId, orgId } = target;
// Best-effort identity resolution - not yet enforced, but lets us
// start making per-user access decisions (e.g. model/role-based
// restrictions) without another round of plumbing later.
const requestUser = await resolveRequestUser(req, resourceId, orgId);
if (requestUser) {
logger.debug(
`AI gateway request from user ${requestUser.userId} (${requestUser.username})`
);
}
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";
+151
View File
@@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import {
aiBudgetUnitSchema,
refineBudgetFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
modelKey: z.string().nonempty(),
name: z.string().nonempty(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "put",
path: "/ai-provider/{providerId}/model",
description: "Create an AI model under a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const { modelKey, name, budgetAmount, budgetUnit, enabled } =
parsedBody.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, providerId),
eq(aiModels.modelKey, modelKey)
)
)
.limit(1);
if (existing) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${modelKey} already exists for this provider`
)
);
}
const now = Date.now();
const [model] = await db
.insert(aiModels)
.values({
providerId,
modelKey,
name,
budgetAmount: budgetAmount ?? null,
budgetUnit: budgetUnit ?? null,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,150 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty(),
type: aiProviderTypeSchema,
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineProviderUpstreamFields(data, ctx);
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/ai-provider",
description: "Create an AI provider for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
name,
type,
upstreamUrl,
apiKey,
authType,
routingMode,
skipTlsVerification,
budgetAmount,
budgetUnit,
enabled
} = parsedBody.data;
const key = config.getRawConfig().server.secret!;
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
const now = Date.now();
const resolvedRoutingMode =
type === "custom" ? (routingMode ?? "url") : "url";
const [provider] = await db
.insert(aiProviders)
.values({
orgId,
name,
type,
upstreamUrl:
resolvedRoutingMode === "target"
? null
: (upstreamUrl ?? null),
apiKey: encryptedApiKey,
apiKeyLastChars,
authType: authType ?? null,
routingMode: resolvedRoutingMode,
skipTlsVerification: skipTlsVerification ?? false,
budgetAmount: budgetAmount ?? null,
budgetUnit: budgetUnit ?? null,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,79 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-model/{modelId}",
description: "Delete an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI model deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-provider/{providerId}",
description: "Delete an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [existing] = await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
await db
.delete(aiProviders)
.where(eq(aiProviders.providerId, providerId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI provider deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-model/{modelId}",
description: "Get an AI model by ID.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [model] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
return response<GetAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,82 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
return response<GetAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./createAiModel";
export * from "./listAiModels";
export * from "./getAiModel";
export * from "./updateAiModel";
export * from "./deleteAiModel";
export * from "./types";
+160
View File
@@ -0,0 +1,160 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/models",
description: "List AI models for a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiModels.providerId, providerId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiModels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiModels)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiModels)
.where(and(...conditions))
.as("filtered_ai_models")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiModels.name))
]);
return response<ListAiModelsResponse>(res, {
data: {
models: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,152 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-providers",
description: "List AI providers for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiProviders.orgId, orgId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiProviders.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiProviders)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiProviders)
.where(and(...conditions))
.as("filtered_ai_providers")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiProviders.name))
]);
return response<ListAiProvidersResponse>(res, {
data: {
providers: rows.map(toPublicAiProvider),
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI providers retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { AiModel, AiProvider } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
resolveAiProviderConfig,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType | null;
};
export type ListAiProvidersResponse = PaginatedResponse<{
providers: AiProviderPublic[];
}>;
export type GetAiProviderResponse = {
provider: AiProviderPublic;
};
export type CreateOrEditAiProviderResponse = {
provider: AiProviderPublic;
};
export type ListAiModelsResponse = PaginatedResponse<{
models: AiModel[];
}>;
export type GetAiModelResponse = {
model: AiModel;
};
export type CreateOrEditAiModelResponse = {
model: AiModel;
};
export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
const { apiKey: _apiKey, ...rest } = provider;
const resolved = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
return {
...rest,
effectiveUpstreamUrl: resolved.upstreamUrl,
effectiveAuthType: resolved.authType
};
}
+167
View File
@@ -0,0 +1,167 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import {
aiBudgetUnitSchema,
refineBudgetFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
modelKey: z.string().nonempty().optional(),
name: z.string().nonempty().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "post",
path: "/ai-model/{modelId}",
description: "Update an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (
body.modelKey !== undefined &&
body.modelKey !== existing.modelKey
) {
const [conflict] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, existing.providerId),
eq(aiModels.modelKey, body.modelKey),
ne(aiModels.modelId, modelId)
)
)
.limit(1);
if (conflict) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${body.modelKey} already exists for this provider`
)
);
}
}
const updateData: Partial<typeof aiModels.$inferInsert> = {
updatedAt: Date.now()
};
if (body.modelKey !== undefined) {
updateData.modelKey = body.modelKey;
}
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.budgetAmount !== undefined) {
updateData.budgetAmount = body.budgetAmount;
}
if (body.budgetUnit !== undefined) {
updateData.budgetUnit = body.budgetUnit;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
const [model] = await db
.update(aiModels)
.set(updateData)
.where(eq(aiModels.modelId, modelId))
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,208 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import type {
AiProviderRoutingMode,
AiProviderType
} from "@server/lib/aiProviderDefaults";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty().optional(),
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "post",
path: "/ai-provider/{providerId}",
description: "Update an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const providerType = existing.type as AiProviderType;
const nextRoutingMode: AiProviderRoutingMode =
providerType === "custom"
? ((body.routingMode ??
existing.routingMode) as AiProviderRoutingMode)
: "url";
const nextUpstreamUrl =
body.upstreamUrl !== undefined
? body.upstreamUrl
: existing.upstreamUrl;
const nextAuthType =
body.authType !== undefined ? body.authType : existing.authType;
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema.nullable().optional(),
routingMode: aiRoutingModeSchema.optional()
})
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
.safeParse({
type: providerType,
upstreamUrl: nextUpstreamUrl,
authType: nextAuthType,
routingMode: nextRoutingMode
});
if (!validation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(validation.error).toString()
)
);
}
const updateData: Partial<typeof aiProviders.$inferInsert> = {
updatedAt: Date.now(),
routingMode: nextRoutingMode
};
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.skipTlsVerification !== undefined) {
updateData.skipTlsVerification = body.skipTlsVerification;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
if (body.budgetAmount !== undefined) {
updateData.budgetAmount = body.budgetAmount;
}
if (body.budgetUnit !== undefined) {
updateData.budgetUnit = body.budgetUnit;
}
if (nextRoutingMode === "target") {
updateData.upstreamUrl = null;
} else if (body.upstreamUrl !== undefined) {
updateData.upstreamUrl = body.upstreamUrl;
}
if (body.authType !== undefined) {
updateData.authType = body.authType;
}
if (body.apiKey !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.apiKey = encrypt(body.apiKey, key);
updateData.apiKeyLastChars = body.apiKey.slice(-4);
}
const [provider] = await db
.update(aiProviders)
.set(updateData)
.where(eq(aiProviders.providerId, providerId))
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from "zod";
import {
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
export const aiProviderTypeSchema = z.enum([
"openai",
"anthropic",
"googleGemini",
"vertexAi",
"bedrock",
"microsoftFoundry",
"openRouter",
"vercelAiGateway",
"custom"
]);
export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
export const aiAuthTypeSchema = z.enum(["bearer"]);
export const aiRoutingModeSchema = z.enum(["url", "target"]);
export function refineBudgetFields(
data: {
budgetAmount?: number | null;
budgetUnit?: AiBudgetUnit | null;
},
ctx: z.RefinementCtx
) {
const hasAmount =
data.budgetAmount !== undefined && data.budgetAmount !== null;
const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null;
if (hasAmount !== hasUnit) {
ctx.addIssue({
code: "custom",
message:
"budgetAmount and budgetUnit must both be set or both omitted",
path: hasAmount ? ["budgetUnit"] : ["budgetAmount"]
});
}
}
export function refineProviderUpstreamFields(
data: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: "bearer" | null;
routingMode?: AiProviderRoutingMode | null;
},
ctx: z.RefinementCtx
) {
const routingMode = data.routingMode ?? "url";
if (data.type !== "custom" && routingMode === "target") {
ctx.addIssue({
code: "custom",
message: "routingMode target is only allowed for custom providers",
path: ["routingMode"]
});
}
if (
providerRequiresUpstreamUrl(data.type, routingMode) &&
!data.upstreamUrl
) {
ctx.addIssue({
code: "custom",
message: `upstreamUrl is required for ${data.type} providers`,
path: ["upstreamUrl"]
});
}
if (data.type === "custom" && routingMode === "url" && !data.authType) {
ctx.addIssue({
code: "custom",
message: "authType is required for custom providers",
path: ["authType"]
});
}
}
+3 -1
View File
@@ -219,7 +219,9 @@ export async function verifyResourceSession(
} }
const { blockAccess, mode } = resource; const { blockAccess, mode } = resource;
const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode); const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes(
mode
);
if (blockAccess) { if (blockAccess) {
logger.debug("Resource blocked", host); logger.debug("Resource blocked", host);
-15
View File
@@ -255,20 +255,6 @@ export async function createClient(
let newClient: Client | null = null; let newClient: Client | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
)
);
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -287,7 +273,6 @@ export async function createClient(
.insert(clients) .insert(clients)
.values({ .values({
niceId, niceId,
exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
name, name,
subnet: updatedSubnet, subnet: updatedSubnet,
@@ -222,11 +222,6 @@ export async function createUserClient(
let newClient: Client | null = null; let newClient: Client | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -244,7 +239,6 @@ export async function createUserClient(
[newClient] = await trx [newClient] = await trx
.insert(clients) .insert(clients)
.values({ .values({
exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
niceId, niceId,
name, name,
+17 -6
View File
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["megabytesIn", "megabytesOut"]) .enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["megabytesIn", "megabytesOut"], enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
description: "Field to sort by" description: "Field to sort by"
}), }),
order: z order: z
@@ -183,7 +183,9 @@ function queryUserDevicesBase() {
fingerprintArch: currentFingerprint.arch, fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username, fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname fingerprintHostname: currentFingerprint.hostname,
firstSeen: currentFingerprint.firstSeen,
lastSeen: currentFingerprint.lastSeen
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))
@@ -389,14 +391,23 @@ export async function listUserDevices(
const countQuery = db.$count(baseQuery.as("filtered_clients")); const countQuery = db.$count(baseQuery.as("filtered_clients"));
const sortColumn =
sort_by === "firstSeen"
? currentFingerprint.firstSeen
: sort_by === "lastSeen"
? currentFingerprint.lastSeen
: sort_by
? clients[sort_by]
: undefined;
const listDevicesQuery = baseQuery const listDevicesQuery = baseQuery
.limit(pageSize) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy( .orderBy(
sort_by sortColumn
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(sortColumn)
: desc(clients[sort_by]) : desc(sortColumn)
: asc(clients.clientId) : asc(clients.clientId)
); );
+127 -1
View File
@@ -45,7 +45,9 @@ import {
verifySiteResourceAccess, verifySiteResourceAccess,
verifyOlmAccess, verifyOlmAccess,
verifyLimits, verifyLimits,
verifyResourcePolicyAccess verifyResourcePolicyAccess,
verifyAiProviderAccess,
verifyAiModelAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -55,6 +57,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares"; import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws"; import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels"; import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@@ -404,6 +407,13 @@ authenticated.get(
siteResource.listSiteResourceClients siteResource.listSiteResourceClients
); );
authenticated.get(
"/site-resource/:siteResourceId/ai-models",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
siteResource.listSiteResourceAiModels
);
authenticated.post( authenticated.post(
"/site-resource/:siteResourceId/roles", "/site-resource/:siteResourceId/roles",
verifySiteResourceAccess, verifySiteResourceAccess,
@@ -414,6 +424,14 @@ authenticated.post(
siteResource.setSiteResourceRoles siteResource.setSiteResourceRoles
); );
authenticated.post(
"/site-resource/:siteResourceId/ai-models",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.setSiteResourceAiModels
);
authenticated.post( authenticated.post(
"/site-resource/:siteResourceId/users", "/site-resource/:siteResourceId/users",
verifySiteResourceAccess, verifySiteResourceAccess,
@@ -648,6 +666,13 @@ authenticated.get(
resource.listResourceUsers resource.listResourceUsers
); );
authenticated.get(
"/resource/:resourceId/ai-models",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
resource.listResourceAiModels
);
authenticated.get( authenticated.get(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
@@ -851,6 +876,14 @@ authenticated.post(
resource.setResourceUsers resource.setResourceUsers
); );
authenticated.post(
"/resource/:resourceId/ai-models",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.setResourceAiModels
);
authenticated.put( authenticated.put(
"/resource-policy/:resourcePolicyId/access-control", "/resource-policy/:resourcePolicyId/access-control",
verifyResourcePolicyAccess, verifyResourcePolicyAccess,
@@ -1366,6 +1399,99 @@ authenticated.get(
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
authenticated.put(
"/org/:orgId/ai-provider",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createAiProvider),
logActionAudit(ActionsEnum.createAiProvider),
aiProvider.createAiProvider
);
authenticated.get(
"/org/:orgId/ai-providers",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAiProviders),
aiProvider.listAiProviders
);
authenticated.get(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put(
"/ai-provider/:providerId/target",
verifyAiProviderAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
target.createTarget
);
authenticated.get(
"/ai-provider/:providerId/targets",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listTargets),
target.listTargets
);
authenticated.post(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.updateAiProvider),
logActionAudit(ActionsEnum.updateAiProvider),
aiProvider.updateAiProvider
);
authenticated.delete(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.deleteAiProvider),
logActionAudit(ActionsEnum.deleteAiProvider),
aiProvider.deleteAiProvider
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.createAiModel),
logActionAudit(ActionsEnum.createAiModel),
aiProvider.createAiModel
);
authenticated.get(
"/ai-provider/:providerId/models",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listAiModels),
aiProvider.listAiModels
);
authenticated.get(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.getAiModel),
aiProvider.getAiModel
);
authenticated.post(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.updateAiModel),
logActionAudit(ActionsEnum.updateAiModel),
aiProvider.updateAiModel
);
authenticated.delete(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.deleteAiModel),
logActionAudit(ActionsEnum.deleteAiModel),
aiProvider.deleteAiModel
);
authenticated.get( authenticated.get(
"/org/:orgId/labels", "/org/:orgId/labels",
verifyOrgAccess, verifyOrgAccess,
+4 -4
View File
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Filter to sites with the required fields up front so the rest of the // Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined. // function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter( const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort (s) => s.endpoint && s.exitNodeSubnet && s.listenPort
); );
if (validSites.length === 0) { if (validSites.length === 0) {
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
if ( if (
peer.orgId == null || peer.orgId == null ||
!peer.endpoint || !peer.endpoint ||
!peer.subnet || !peer.exitNodeSubnet ||
!peer.listenPort !peer.listenPort
) { ) {
continue; continue;
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Process each site using the pre-fetched data. // Process each site using the pre-fetched data.
for (const site of validSites) { for (const site of validSites) {
const siteDestination: PeerDestination = { const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0], destinationIP: site.exitNodeSubnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}; };
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
continue; continue;
} }
addDestination(site.endpoint!, { addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0], destinationIP: peer.exitNodeSubnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
+2 -2
View File
@@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
and( and(
eq(sites.exitNodeId, exitNode.exitNodeId), eq(sites.exitNodeId, exitNode.exitNodeId),
isNotNull(sites.pubKey), isNotNull(sites.pubKey),
isNotNull(sites.subnet) isNotNull(sites.exitNodeSubnet)
) )
); );
@@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
} else if (site.type === "newt") { } else if (site.type === "newt") {
return { return {
publicKey: site.pubKey, publicKey: site.pubKey,
allowedIps: [site.subnet!] allowedIps: [site.exitNodeSubnet!]
}; };
} }
return { return {
+1 -1
View File
@@ -186,7 +186,7 @@ export async function updateAndGenerateEndpointDestinations(
.select({ .select({
siteId: sites.siteId, siteId: sites.siteId,
newtId: newts.newtId, newtId: newts.newtId,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
listenPort: sites.listenPort, listenPort: sites.listenPort,
publicKey: sites.publicKey, publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint, endpoint: clientSitesAssociationsCache.endpoint,
+181
View File
@@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys";
import * as idp from "./idp"; import * as idp from "./idp";
import * as logs from "./auditLogs"; import * as logs from "./auditLogs";
import * as siteResource from "./siteResource"; import * as siteResource from "./siteResource";
import * as aiProvider from "./aiProvider";
import { import {
verifyApiKey, verifyApiKey,
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -31,6 +32,8 @@ import {
verifyLimits, verifyLimits,
verifyApiKeyDomainAccess, verifyApiKeyDomainAccess,
verifyApiKeyResourcePolicyAccess, verifyApiKeyResourcePolicyAccess,
verifyApiKeyAiProviderAccess,
verifyApiKeyAiModelAccess,
verifyUserHasAction verifyUserHasAction
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -243,6 +246,16 @@ authenticated.get(
siteResource.listSiteResourceClients siteResource.listSiteResourceClients
); );
authenticated.get(
[
"/site-resource/:siteResourceId/ai-models",
"/private-resource/:siteResourceId/ai-models"
],
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
siteResource.listSiteResourceAiModels
);
authenticated.post( authenticated.post(
[ [
"/site-resource/:siteResourceId/roles", "/site-resource/:siteResourceId/roles",
@@ -295,6 +308,39 @@ authenticated.post(
siteResource.removeRoleFromSiteResource siteResource.removeRoleFromSiteResource
); );
authenticated.post(
[
"/site-resource/:siteResourceId/ai-models",
"/private-resource/:siteResourceId/ai-models"
],
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.setSiteResourceAiModels
);
authenticated.post(
[
"/site-resource/:siteResourceId/ai-models/add",
"/private-resource/:siteResourceId/ai-models/add"
],
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.addAiModelToSiteResource
);
authenticated.post(
[
"/site-resource/:siteResourceId/ai-models/remove",
"/private-resource/:siteResourceId/ai-models/remove"
],
verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.removeAiModelFromSiteResource
);
authenticated.post( authenticated.post(
[ [
"/site-resource/:siteResourceId/users/add", "/site-resource/:siteResourceId/users/add",
@@ -507,6 +553,16 @@ authenticated.get(
resource.listResourceUsers resource.listResourceUsers
); );
authenticated.get(
[
"/resource/:resourceId/ai-models",
"/public-resource/:resourceId/ai-models"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.listResourceAiModels),
resource.listResourceAiModels
);
authenticated.get( authenticated.get(
["/resource/:resourceId", "/public-resource/:resourceId"], ["/resource/:resourceId", "/public-resource/:resourceId"],
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,
@@ -708,6 +764,17 @@ authenticated.post(
resource.setResourceRoles resource.setResourceRoles
); );
authenticated.post(
[
"/resource/:resourceId/ai-models",
"/public-resource/:resourceId/ai-models"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.setResourceAiModels
);
authenticated.post( authenticated.post(
["/resource/:resourceId/users", "/public-resource/:resourceId/users"], ["/resource/:resourceId/users", "/public-resource/:resourceId/users"],
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,
@@ -900,6 +967,28 @@ authenticated.post(
resource.removeRoleFromResource resource.removeRoleFromResource
); );
authenticated.post(
[
"/resource/:resourceId/ai-models/add",
"/public-resource/:resourceId/ai-models/add"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.addAiModelToResource
);
authenticated.post(
[
"/resource/:resourceId/ai-models/remove",
"/public-resource/:resourceId/ai-models/remove"
],
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.removeAiModelFromResource
);
authenticated.post( authenticated.post(
[ [
"/resource/:resourceId/users/add", "/resource/:resourceId/users/add",
@@ -1366,3 +1455,95 @@ authenticated.get(
verifyApiKeyHasAction(ActionsEnum.listResources), verifyApiKeyHasAction(ActionsEnum.listResources),
resource.listAllResourceNames resource.listAllResourceNames
); );
authenticated.put(
"/org/:orgId/ai-provider",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createAiProvider),
logActionAudit(ActionsEnum.createAiProvider),
aiProvider.createAiProvider
);
authenticated.get(
"/org/:orgId/ai-providers",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listAiProviders),
aiProvider.listAiProviders
);
authenticated.get(
"/ai-provider/:providerId",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put(
"/ai-provider/:providerId/target",
verifyApiKeyAiProviderAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
target.createTarget
);
authenticated.get(
"/ai-provider/:providerId/targets",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.listTargets),
target.listTargets
);
authenticated.post(
"/ai-provider/:providerId",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.updateAiProvider),
logActionAudit(ActionsEnum.updateAiProvider),
aiProvider.updateAiProvider
);
authenticated.delete(
"/ai-provider/:providerId",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.deleteAiProvider),
logActionAudit(ActionsEnum.deleteAiProvider),
aiProvider.deleteAiProvider
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.createAiModel),
logActionAudit(ActionsEnum.createAiModel),
aiProvider.createAiModel
);
authenticated.get(
"/ai-provider/:providerId/models",
verifyApiKeyAiProviderAccess,
verifyApiKeyHasAction(ActionsEnum.listAiModels),
aiProvider.listAiModels
);
authenticated.get(
"/ai-model/:modelId",
verifyApiKeyAiModelAccess,
verifyApiKeyHasAction(ActionsEnum.getAiModel),
aiProvider.getAiModel
);
authenticated.post(
"/ai-model/:modelId",
verifyApiKeyAiModelAccess,
verifyApiKeyHasAction(ActionsEnum.updateAiModel),
logActionAudit(ActionsEnum.updateAiModel),
aiProvider.updateAiModel
);
authenticated.delete(
"/ai-model/:modelId",
verifyApiKeyAiModelAccess,
verifyApiKeyHasAction(ActionsEnum.deleteAiModel),
logActionAudit(ActionsEnum.deleteAiModel),
aiProvider.deleteAiModel
);
+8
View File
@@ -3,6 +3,7 @@ import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as resource from "./resource"; import * as resource from "./resource";
import * as badger from "./badger"; import * as badger from "./badger";
import * as aiGateway from "@server/routers/aiGateway";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey"; import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp"; import * as idp from "@server/routers/idp";
@@ -63,3 +64,10 @@ internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession); 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
);
@@ -1,7 +1,8 @@
import { SiteResource } from "@server/db";
import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
export type SiteResourceDestinationInput = { export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh"; mode: SiteResource["mode"];
destination: string | null; destination: string | null;
destinationPort: number | null; destinationPort: number | null;
scheme: "http" | "https" | null; scheme: "http" | "https" | null;
+10 -6
View File
@@ -15,7 +15,7 @@ import {
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import { import {
@@ -211,7 +211,8 @@ export async function buildClientConfigurationForNewtClient(
// call rather than letting each resource fetch its own — with thousands // call rather than letting each resource fetch its own — with thousands
// of resources this avoids a concurrent DB/cache stampede for what is // of resources this avoids a concurrent DB/cache stampede for what is
// often the very same (e.g. wildcard) certificate. // often the very same (e.g. wildcard) certificate.
const certByDomain = await batchFetchCertsForSiteResources(allSiteResources); const certByDomain =
await batchFetchCertsForSiteResources(allSiteResources);
const resourceTargetsArr = await Promise.all( const resourceTargetsArr = await Promise.all(
allSiteResources.map((resource) => allSiteResources.map((resource) =>
@@ -240,7 +241,7 @@ export async function buildTargetConfigurationForNewtClient(
version?: string | null, version?: string | null,
remoteExitNodeId?: string remoteExitNodeId?: string
) { ) {
// Get all enabled targets with their resource mode information // Get enabled HTTP/TCP/UDP targets for resources and AI providers
const allTargets = await db const allTargets = await db
.select({ .select({
resourceId: targets.resourceId, resourceId: targets.resourceId,
@@ -250,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled, enabled: targets.enabled,
mode: resources.mode mode: sql<string>`COALESCE(${resources.mode}, ${targets.mode})`.mapWith(
String
)
}) })
.from(targets) .from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .leftJoin(resources, eq(targets.resourceId, resources.resourceId))
.where( .where(
and( and(
eq(targets.siteId, siteId), eq(targets.siteId, siteId),
eq(targets.enabled, true), eq(targets.enabled, true),
inArray(targets.mode, ["http", "udp", "tcp"]) inArray(targets.mode, ["http", "udp", "tcp"]),
or(isNotNull(targets.resourceId), isNotNull(targets.providerId))
) )
); );
@@ -2,14 +2,17 @@ import { db, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt } from "@server/db"; import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ne, eq, or, and, count } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { listExitNodes } from "#dynamic/lib/exitNodes"; import { listExitNodes } from "#dynamic/lib/exitNodes";
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => { export const handleNewtExitNodesRequestMessage: MessageHandler = async (
context
) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
logger.info("Handling ping request newt message!"); logger.info("Handling exit nodes request newt message!");
if (!newt) { if (!newt) {
logger.warn("Newt not found"); logger.warn("Newt not found");
@@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesPayload = await Promise.all( const exitNodesPayload = await Promise.all(
exitNodesList.map(async (node) => { exitNodesList.map(async (node) => {
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) const weight = await calculateExitNodeWeight(
// higher = more desirable node.exitNodeId,
// like saying, this node has x% of its capacity left node.maxConnections
);
let weight = 1; if (weight === null) {
const maxConnections = node.maxConnections; return null;
if (maxConnections !== null && maxConnections !== undefined) {
const [currentConnections] = await db
.select({
count: count()
})
.from(sites)
.where(
and(
eq(sites.exitNodeId, node.exitNodeId),
eq(sites.online, true)
)
);
if (currentConnections.count >= maxConnections) {
return null;
}
weight =
(maxConnections - currentConnections.count) /
maxConnections;
} }
return { return {
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
if ( if (
exitNode.reachableAt && exitNode.reachableAt &&
existingSite.subnet && existingSite.exitNodeSubnet &&
existingSite.listenPort existingSite.listenPort
) { ) {
const payload = { const payload = {
oldDestination: { oldDestination: {
destinationIP: existingSite.subnet?.split("/")[0], destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}, },
newDestination: { newDestination: {
destinationIP: site.subnet?.split("/")[0], destinationIP: site.exitNodeSubnet?.split("/")[0],
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
}; };
@@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets)); ({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
} }
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format const targetsToSend = await convertTargetsIfNecessary(
newt.newtId,
dedupedTargets
); // for backward compatibility with old newt versions that don't support the new target format
return { return {
message: { message: {
@@ -1,30 +1,20 @@
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { db, newts, remoteExitNodes } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db"; import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers"; import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { import {
ExitNodePingResult,
selectBestExitNode, selectBestExitNode,
verifyExitNodeOrgAccess verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes"; } from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { lockManager } from "#dynamic/lib/lock";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
@@ -94,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId); fetchContainers(newt.newtId);
} }
let siteSubnet = oldSite.subnet; let siteSubnet = oldSite.exitNodeSubnet;
let exitNodeIdToQuery = oldSite.exitNodeId; let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { if (
exitNodeId &&
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
) {
// This effectively moves the exit node to the new one // This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
const newSubnet = await getUniqueSubnetForSite(exitNode); const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) { if (!newSubnet) {
logger.error( logger.error(
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
exitNodeId: exitNodeId, exitNodeId: exitNodeId,
subnet: newSubnet exitNodeSubnet: newSubnet
}) })
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.returning(); .returning();
@@ -250,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
excludeSender: false // Include sender in broadcast excludeSender: false // Include sender in broadcast
}; };
}; };
async function getUniqueSubnetForSite(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const sitesQuery = await trx
.select({
subnet: sites.subnet
})
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery
.map((site) => site.subnet)
.filter(
(subnet) =>
subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
)
.filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,
blockSize,
exitNode.address
);
return newSubnet;
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage"; export * from "./handleReceiveBandwidthMessage";
export * from "./handleNewtGetConfigMessage"; export * from "./handleNewtGetConfigMessage";
export * from "./handleSocketMessages"; export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage"; export * from "./handleNewtExitNodesRequestMessage";
export * from "./handleApplyBlueprintMessage"; export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage"; export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage"; export * from "./handleNewtDisconnectingMessage";
+29 -8
View File
@@ -19,6 +19,7 @@ import logger from "@server/logger";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { SiR } from "react-icons/si";
export async function buildSiteConfigurationForOlmClient( export async function buildSiteConfigurationForOlmClient(
client: Client, client: Client,
@@ -38,6 +39,8 @@ export async function buildSiteConfigurationForOlmClient(
aliases: Alias[]; aliases: Alias[];
}[] = []; }[] = [];
let exitNodeAliases: string[] = [];
// Get all sites data // Get all sites data
const sitesData = await db const sitesData = await db
.select() .select()
@@ -48,10 +51,6 @@ export async function buildSiteConfigurationForOlmClient(
) )
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
if (sitesData.length === 0) {
return siteConfigurations;
}
// Batch-fetch every site resource this client has access to across ALL sites // Batch-fetch every site resource this client has access to across ALL sites
// in a single query, then group by siteId in memory. This avoids issuing one // in a single query, then group by siteId in memory. This avoids issuing one
// query per site (which would be N round-trips for N sites). // query per site (which would be N round-trips for N sites).
@@ -68,8 +67,8 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .leftJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(
and( and(
eq( eq(
@@ -81,7 +80,15 @@ export async function buildSiteConfigurationForOlmClient(
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
let siteResourcesForExitNode = [];
for (const row of allClientSiteResources) { for (const row of allClientSiteResources) {
if (row.siteResource.requiresExitNodeConnection) {
siteResourcesForExitNode.push(row.siteResource);
}
if (!row.siteId) {
// because we are doing a leftJoin above to get the inference resources without a network / sites
continue;
}
const arr = siteResourcesBySiteId.get(row.siteId); const arr = siteResourcesBySiteId.get(row.siteId);
if (arr) { if (arr) {
arr.push(row.siteResource); arr.push(row.siteResource);
@@ -90,6 +97,17 @@ export async function buildSiteConfigurationForOlmClient(
} }
} }
exitNodeAliases = siteResourcesForExitNode
.map((sr) => sr.alias)
.filter((a) => a != null);
if (sitesData.length == 0) {
return {
siteConfigurations,
exitNodeAliases
};
}
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode). // Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>(); const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
if (!jitMode && relay) { if (!jitMode && relay) {
@@ -167,7 +185,7 @@ export async function buildSiteConfigurationForOlmClient(
peerOps.push(deletePeer(site.siteId, client.pubKey!)); peerOps.push(deletePeer(site.siteId, client.pubKey!));
} }
if (!site.subnet) { if (!site.exitNodeSubnet) {
logger.debug(`Site ${site.siteId} has no subnet, skipping`); logger.debug(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -226,5 +244,8 @@ export async function buildSiteConfigurationForOlmClient(
}); });
} }
return siteConfigurations; return {
siteConfigurations,
exitNodeAliases
};
} }
@@ -0,0 +1,93 @@
import { db, clients } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Olm } from "@server/db";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
export const handleOlmExitNodesRequestMessage: MessageHandler = async (
context
) => {
const { message, client: olmClient, sendToClient } = context;
const olm = olmClient as Olm;
logger.info("Handling exit nodes request olm message!");
if (!olm) {
logger.warn("olm not found");
return;
}
// Get the olm's orgId through the client relationship
if (!olm.clientId) {
logger.warn("olm clientId not found");
return;
}
const [client] = await db
.select({ orgId: clients.orgId })
.from(clients)
.where(eq(clients.clientId, olm.clientId))
.limit(1);
if (!client || !client.orgId) {
logger.warn("client not found");
return;
}
const { noCloud, chainId } = message.data;
const exitNodesList = await listExitNodes(
client.orgId,
true,
noCloud || false,
olm.clientId
); // filter for only the online ones
let lastExitNodeId = null;
if (olm.clientId) {
const [lastExitNode] = await db
.select()
.from(clients)
.where(eq(clients.clientId, olm.clientId))
.limit(1);
lastExitNodeId = lastExitNode?.exitNodeId || null;
}
const exitNodesPayload = await Promise.all(
exitNodesList.map(async (node) => {
const weight = await calculateExitNodeWeight(
node.exitNodeId,
node.maxConnections
);
if (weight === null) {
return null;
}
return {
exitNodeId: node.exitNodeId,
exitNodeName: node.name,
endpoint: node.endpoint,
weight,
wasPreviouslyConnected: node.exitNodeId === lastExitNodeId
};
})
);
// filter out null values
const filteredExitNodes = exitNodesPayload.filter((node) => node !== null);
return {
message: {
type: "olm/ping/exitNodes",
data: {
exitNodes: filteredExitNodes,
chainId: chainId
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
};
};
+107 -8
View File
@@ -1,4 +1,4 @@
import { db, orgs, primaryDb } from "@server/db"; import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
@@ -22,6 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
import {
ExitNodePingResult,
selectBestExitNode,
verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -49,11 +55,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olmAgent, olmAgent,
orgId, orgId,
userToken, userToken,
pingResults,
fingerprint, fingerprint,
postures, postures,
backwardsCompatible,
chainId chainId
} = message.data; } = message.data;
if (backwardsCompatible) {
logger.debug(
"[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response."
);
return;
}
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("[handleOlmRegisterMessage] Olm client ID not found"); logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
@@ -284,7 +299,64 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
if (client.pubKey !== publicKey || client.archived) { let exitNodeId: number | undefined;
if (pingResults) {
const bestPingResult = selectBestExitNode(
pingResults as ExitNodePingResult[]
);
if (!bestPingResult) {
logger.warn("No suitable exit node found based on ping results");
}
exitNodeId = bestPingResult?.exitNodeId;
}
let clientSubnet = client.exitNodeSubnet;
if (
exitNodeId &&
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
) {
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
exitNodeId,
client.orgId
);
if (!exitNode) {
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
orgId: client.orgId,
clientId: client.clientId
});
return;
}
if (!hasAccess) {
logger.warn(
"[handleOlmRegisterMessage] Not authorized to use this exit node",
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
// TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT?
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) {
logger.error(
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
clientSubnet = newSubnet;
}
if (
client.pubKey !== publicKey ||
client.archived ||
client.exitNodeId !== exitNodeId ||
client.exitNodeSubnet !== clientSubnet
) {
logger.info( logger.info(
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
{ orgId: client.orgId, clientId: client.clientId } { orgId: client.orgId, clientId: client.clientId }
@@ -294,7 +366,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.update(clients) .update(clients)
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
archived: false archived: false,
exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
exitNodeSubnet: clientSubnet
}) })
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
@@ -376,14 +450,28 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
let exitNode: ExitNode | null = null;
if (exitNodeId) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
}
// NOTE: its important that the client here is the old client and the public key is the new key // NOTE: its important that the client here is the old client and the public key is the new key
await waitForClientRebuildIdle(olm.clientId); await waitForClientRebuildIdle(olm.clientId);
const siteConfigurations = await buildSiteConfigurationForOlmClient( const { siteConfigurations, exitNodeAliases } =
client, await buildSiteConfigurationForOlmClient(
publicKey, client,
relay, publicKey,
jitMode relay,
jitMode
);
logger.info(
`+++++++++++++++++++++++++++++++ ExitNode Aliases: ${exitNodeAliases}`
); );
// Return connect message with all site configurations // Return connect message with all site configurations
@@ -394,6 +482,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sites: siteConfigurations, sites: siteConfigurations,
tunnelIP: client.subnet, tunnelIP: client.subnet,
utilitySubnet: org.utilitySubnet, utilitySubnet: org.utilitySubnet,
exitNode:
exitNode && client.exitNodeSubnet
? {
aliases: exitNodeAliases,
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0]
}
: undefined,
chainId: chainId chainId: chainId
} }
}, },
+1
View File
@@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./handleOlmUnLocalMessage"; export * from "./handleOlmUnLocalMessage";
export * from "./handleOlmLocalMessage"; export * from "./handleOlmLocalMessage";
export * from "./handleOlmExitNodesRequestMessage";
@@ -0,0 +1,161 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels, aiModels } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const addAiModelToResourceBodySchema = z.strictObject({
modelId: z.int().positive()
});
const addAiModelToResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/add",
description:
"Add a single AI model to a resource's model restriction allow-list.",
tags: [OpenAPITags.PublicResource],
request: {
params: addAiModelToResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addAiModelToResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function addAiModelToResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addAiModelToResourceBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedBody.data;
const parsedParams = addAiModelToResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
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")
);
}
if (!resource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no AI provider linked"
)
);
}
const [model] = await db
.select()
.from(aiModels)
.where(
and(
eq(aiModels.modelId, modelId),
eq(aiModels.providerId, resource.aiProviderId)
)
)
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found or does not belong to this resource's AI provider"
)
);
}
const existingEntry = await db
.select()
.from(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Model already assigned to resource"
)
);
}
await db.insert(resourceAiModels).values({ resourceId, modelId });
return response(res, {
data: {},
success: true,
error: false,
message: "Model added to resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+19 -6
View File
@@ -44,11 +44,11 @@ const createResourceParamsSchema = z.strictObject({
}); });
function resolveModeFromLegacyFields(data: { function resolveModeFromLegacyFields(data: {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
http?: boolean; http?: boolean;
protocol?: "tcp" | "udp"; protocol?: "tcp" | "udp";
}): { }): {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
error?: string; error?: string;
} { } {
if (data.mode) { if (data.mode) {
@@ -90,11 +90,22 @@ const createHttpResourceSchema = z
domainId: z.string(), domainId: z.string(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().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 // SSH Settings
pamMode: z.enum(["passthrough", "push"]).optional(), pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonPort: z.int().positive().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( .refine(
(data) => { (data) => {
@@ -365,7 +376,8 @@ async function createHttpResource(
mode, mode,
authDaemonPort, authDaemonPort,
authDaemonMode, authDaemonMode,
pamMode pamMode,
aiProviderId
} = parsedBody.data; } = parsedBody.data;
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; const stickySession = parsedBody.data.stickySession;
@@ -552,7 +564,8 @@ async function createHttpResource(
postAuthPath: postAuthPath, postAuthPath: postAuthPath,
wildcard, wildcard,
health: "unknown", health: "unknown",
defaultResourcePolicyId: defaultPolicy.resourcePolicyId defaultResourcePolicyId: defaultPolicy.resourcePolicyId,
aiProviderId: aiProviderId ?? null
}) })
.returning(); .returning();
+4
View File
@@ -35,3 +35,7 @@ export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory"; export * from "./getStatusHistory";
export * from "./getBatchedStatusHistory"; export * from "./getBatchedStatusHistory";
export * from "./getResourcePolicies"; export * from "./getResourcePolicies";
export * from "./listResourceAiModels";
export * from "./setResourceAiModels";
export * from "./addAiModelToResource";
export * from "./removeAiModelFromResource";
@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels, aiModels } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceAiModelsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
async function query(resourceId: number) {
return await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
enabled: aiModels.enabled
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceId));
}
export type ListResourceAiModelsResponse = {
models: NonNullable<Awaited<ReturnType<typeof query>>>;
};
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-models",
description:
"List the AI models a resource is restricted to. An empty list means the resource is not restricted and every enabled model on its linked AI provider is allowed.",
tags: [OpenAPITags.PublicResource],
request: {
params: listResourceAiModelsParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
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 models = await query(resourceId);
return response<ListResourceAiModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Resource AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+2 -1
View File
@@ -637,11 +637,12 @@ export async function listResources(
${resourcePassword.passwordId} ${resourcePassword.passwordId}
) )
`; `;
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"]; const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
switch (authState) { switch (authState) {
case "none": case "none":
conditions.push( conditions.push(
// TODO: Does inference belong here?
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp")) or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
); );
break; break;
@@ -0,0 +1,141 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const removeAiModelFromResourceBodySchema = z.strictObject({
modelId: z.int().positive()
});
const removeAiModelFromResourceParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models/remove",
description:
"Remove a single AI model from a resource's model restriction allow-list.",
tags: [OpenAPITags.PublicResource],
request: {
params: removeAiModelFromResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeAiModelFromResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function removeAiModelFromResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = removeAiModelFromResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedBody.data;
const parsedParams = removeAiModelFromResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
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 existingEntry = await db
.select()
.from(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found in resource's restriction list"
)
);
}
await db
.delete(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
eq(resourceAiModels.modelId, modelId)
)
);
return response(res, {
data: {},
success: true,
error: false,
message: "Model removed from resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,155 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, resourceAiModels, aiModels } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceAiModelsBodySchema = z.strictObject({
modelIds: z.array(z.int().positive())
});
const setResourceAiModelsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/ai-models",
description:
"Set the AI models a resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).",
tags: [OpenAPITags.PublicResource],
request: {
params: setResourceAiModelsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAiModelsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourceAiModelsBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelIds } = parsedBody.data;
const parsedParams = setResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
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")
);
}
if (modelIds.length > 0) {
if (!resource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no AI provider linked"
)
);
}
const validModels = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
inArray(aiModels.modelId, modelIds),
eq(aiModels.providerId, resource.aiProviderId)
)
);
if (validModels.length !== new Set(modelIds).size) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more model IDs do not exist or do not belong to this resource's AI provider"
)
);
}
}
await db.transaction(async (trx) => {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
if (modelIds.length > 0) {
await trx
.insert(resourceAiModels)
.values(
modelIds.map((modelId) => ({ resourceId, modelId }))
);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "AI models set for resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -120,6 +120,15 @@ const updateHttpResourceBodySchema = z
.optional() .optional()
.describe( .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." "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, { .refine((data) => Object.keys(data).length > 0, {
+4 -4
View File
@@ -311,13 +311,13 @@ export async function createSite(
// lets also make sure there is no overlap with other sites on the exit node // lets also make sure there is no overlap with other sites on the exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where( .where(
and( and(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
eq(sites.subnet, subnet) eq(sites.exitNodeSubnet, subnet)
) )
); );
@@ -427,7 +427,7 @@ export async function createSite(
exitNodeId, exitNodeId,
name, name,
niceId: updatedNiceId!, niceId: updatedNiceId!,
subnet, exitNodeSubnet: subnet,
type, type,
pubKey: pubKey || null, pubKey: pubKey || null,
status: "approved" status: "approved"
@@ -444,7 +444,7 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/32", exitNodeSubnet: "0.0.0.0/32",
status: "approved" status: "approved"
}) })
.returning(); .returning();
+1 -1
View File
@@ -125,7 +125,7 @@ function querySitesBase() {
niceId: sites.niceId, niceId: sites.niceId,
name: sites.name, name: sites.name,
pubKey: sites.pubKey, pubKey: sites.pubKey,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
megabytesIn: sites.megabytesIn, megabytesIn: sites.megabytesIn,
megabytesOut: sites.megabytesOut, megabytesOut: sites.megabytesOut,
orgName: orgs.name, orgName: orgs.name,
+4 -3
View File
@@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({
clientAddress: z.string().optional() clientAddress: z.string().optional()
}); });
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/pick-site-defaults", path: "/org/{orgId}/pick-site-defaults",
@@ -60,7 +59,9 @@ registry.registerPath({
description: "Successful response", description: "Successful response",
content: { content: {
"application/json": { "application/json": {
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema) schema: createApiResponseSchema(
PickSiteDefaultsResponseDataSchema
)
} }
} }
} }
@@ -108,7 +109,7 @@ export async function pickSiteDefaults(
// list all of the sites on that exit node // list all of the sites on that exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); .where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
@@ -0,0 +1,170 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const addAiModelToSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
});
const addAiModelToSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/add",
description:
"Add a single AI model to a site resource's model restriction allow-list.",
tags: [OpenAPITags.PrivateResource],
request: {
params: addAiModelToSiteResourceParamsSchema,
body: {
content: {
"application/json": {
schema: addAiModelToSiteResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function addAiModelToSiteResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addAiModelToSiteResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedBody.data;
const parsedParams = addAiModelToSiteResourceParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
if (!siteResource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site resource has no AI provider linked"
)
);
}
const [model] = await db
.select()
.from(aiModels)
.where(
and(
eq(aiModels.modelId, modelId),
eq(aiModels.providerId, siteResource.aiProviderId)
)
)
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found or does not belong to this site resource's AI provider"
)
);
}
const existingEntry = await db
.select()
.from(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
eq(siteResourceAiModels.modelId, modelId)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Model already assigned to site resource"
)
);
}
await db
.insert(siteResourceAiModels)
.values({ siteResourceId, modelId });
return response(res, {
data: {},
success: true,
error: false,
message: "Model added to site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -29,7 +29,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -49,7 +49,7 @@ const createSiteResourceSchema = z
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
niceId: z.string().optional(), niceId: z.string().optional(),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
mode: z.enum(["host", "cidr", "http", "ssh"]), mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
ssl: z.boolean().optional(), // only used for http mode ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()).optional(), siteIds: z.array(z.int()).optional(),
@@ -78,7 +78,15 @@ const createSiteResourceSchema = z
authDaemonMode: z.enum(["site", "remote", "native"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).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 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() .strict()
.refine( .refine(
@@ -171,6 +179,9 @@ const createSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.mode == "inference") {
return true;
}
return ( return (
(data.siteIds !== undefined && data.siteIds.length > 0) || (data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined data.siteId !== undefined
@@ -319,7 +330,8 @@ export async function createSiteResource(
authDaemonMode, authDaemonMode,
pamMode, pamMode,
domainId, domainId,
subdomain subdomain,
aiProviderId
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array // Backward compatibility: merge deprecated siteId into siteIds array
@@ -487,7 +499,11 @@ export async function createSiteResource(
.where( .where(
and( and(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.alias, alias.trim()) eq(siteResources.alias, alias.trim()),
ne(
siteResources.requiresExitNodeConnection,
mode == "inference"
) // exclude looking at the ones on exit nodes if this is an inference resource
) )
) )
.limit(1); .limit(1);
@@ -524,6 +540,7 @@ export async function createSiteResource(
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null; let releaseAliasLock: (() => Promise<void>) | null = null;
if (mode === "host" || mode === "http" || mode === "ssh") { if (mode === "host" || mode === "http" || mode === "ssh") {
// no alias address but we do have an alias for inference
const { value, release } = const { value, release } =
await getNextAvailableAliasAddress(orgId); await getNextAvailableAliasAddress(orgId);
aliasAddress = value; aliasAddress = value;
@@ -533,21 +550,24 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
try { try {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [network] = await trx let network: typeof networks.$inferSelect | undefined;
.insert(networks) if (mode !== "inference") {
.values({ [network] = await trx
scope: "resource", .insert(networks)
orgId: orgId .values({
}) scope: "resource",
.returning(); orgId: orgId
})
.returning();
if (!network) { if (!network) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Failed to create network` `Failed to create network`
) )
); );
}
} }
let tcpPortRangeStringAdjusted = tcpPortRangeString; let tcpPortRangeStringAdjusted = tcpPortRangeString;
@@ -566,7 +586,7 @@ export async function createSiteResource(
name, name,
mode, mode,
ssl, ssl,
networkId: network.networkId, networkId: network ? network.networkId : null,
destination: destination, // the ssh can be null destination: destination, // the ssh can be null
scheme, scheme,
destinationPort, destinationPort,
@@ -582,7 +602,9 @@ export async function createSiteResource(
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain fullDomain,
requiresExitNodeConnection: mode === "inference", // in the future we might want to have different modes that do this
aiProviderId: aiProviderId ?? null
}; };
if (isLicensedSshPam) { if (isLicensedSshPam) {
if (authDaemonPort !== undefined) if (authDaemonPort !== undefined)
@@ -600,11 +622,13 @@ export async function createSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
for (const siteId of siteIds) { if (network) {
await trx.insert(siteNetworks).values({ for (const siteId of siteIds) {
siteId: siteId, await trx.insert(siteNetworks).values({
networkId: network.networkId siteId: siteId,
}); networkId: network.networkId
});
}
} }
const [adminRole] = await trx const [adminRole] = await trx
+4
View File
@@ -17,3 +17,7 @@ export * from "./setSiteResourceClients";
export * from "./addClientToSiteResource"; export * from "./addClientToSiteResource";
export * from "./batchAddClientToSiteResources"; export * from "./batchAddClientToSiteResources";
export * from "./removeClientFromSiteResource"; export * from "./removeClientFromSiteResource";
export * from "./listSiteResourceAiModels";
export * from "./setSiteResourceAiModels";
export * from "./addAiModelToSiteResource";
export * from "./removeAiModelFromSiteResource";
@@ -0,0 +1,115 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listSiteResourceAiModelsParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
async function query(siteResourceId: number) {
return await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
enabled: aiModels.enabled
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
}
export type ListSiteResourceAiModelsResponse = {
models: NonNullable<Awaited<ReturnType<typeof query>>>;
};
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"List the AI models a site resource is restricted to. An empty list means the site resource is not restricted and every enabled model on its linked AI provider is allowed.",
tags: [OpenAPITags.PrivateResource],
request: {
params: listSiteResourceAiModelsParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listSiteResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = listSiteResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
const models = await query(siteResourceId);
return response<ListSiteResourceAiModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Site resource AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,140 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, siteResources, siteResourceAiModels } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const removeAiModelFromSiteResourceBodySchema = z.strictObject({
modelId: z.int().positive()
});
const removeAiModelFromSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models/remove",
description:
"Remove a single AI model from a site resource's model restriction allow-list.",
tags: [OpenAPITags.PrivateResource],
request: {
params: removeAiModelFromSiteResourceParamsSchema,
body: {
content: {
"application/json": {
schema: removeAiModelFromSiteResourceBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function removeAiModelFromSiteResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = removeAiModelFromSiteResourceBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedBody.data;
const parsedParams =
removeAiModelFromSiteResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
const existingEntry = await db
.select()
.from(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
eq(siteResourceAiModels.modelId, modelId)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Model not found in site resource's restriction list"
)
);
}
await db
.delete(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
eq(siteResourceAiModels.modelId, modelId)
)
);
return response(res, {
data: {},
success: true,
error: false,
message: "Model removed from site resource successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,167 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
siteResources,
siteResourceAiModels,
aiModels
} from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const setSiteResourceAiModelsBodySchema = z.strictObject({
modelIds: z.array(z.int().positive())
});
const setSiteResourceAiModelsParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site-resource/{siteResourceId}/ai-models",
description:
"Set the AI models a site resource is restricted to. This replaces all existing restrictions. Pass an empty array to remove the restriction (allow every enabled model on the linked provider).",
tags: [OpenAPITags.PrivateResource],
request: {
params: setSiteResourceAiModelsParamsSchema,
body: {
content: {
"application/json": {
schema: setSiteResourceAiModelsBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function setSiteResourceAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setSiteResourceAiModelsBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelIds } = parsedBody.data;
const parsedParams = setSiteResourceAiModelsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
if (modelIds.length > 0) {
if (!siteResource.aiProviderId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site resource has no AI provider linked"
)
);
}
const validModels = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
inArray(aiModels.modelId, modelIds),
eq(aiModels.providerId, siteResource.aiProviderId)
)
);
if (validModels.length !== new Set(modelIds).size) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more model IDs do not exist or do not belong to this site resource's AI provider"
)
);
}
}
await db.transaction(async (trx) => {
await trx
.delete(siteResourceAiModels)
.where(
eq(siteResourceAiModels.siteResourceId, siteResourceId)
);
if (modelIds.length > 0) {
await trx
.insert(siteResourceAiModels)
.values(
modelIds.map((modelId) => ({
siteResourceId,
modelId
}))
);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "AI models set for site resource successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -50,7 +50,7 @@ const updateSiteResourceSchema = z
) )
.optional(), .optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(), mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(), scheme: z.enum(["http", "https"]).nullish(),
destinationPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(),
@@ -78,7 +78,16 @@ const updateSiteResourceSchema = z
authDaemonMode: z.enum(["site", "remote", "native"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(), pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: z.string().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() .strict()
.refine( .refine(
@@ -173,6 +182,9 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.mode == "inference") {
return true;
}
// if neither is provided, the existing site associations are left unchanged // if neither is provided, the existing site associations are left unchanged
if (data.siteIds === undefined && data.siteId === undefined) { if (data.siteIds === undefined && data.siteId === undefined) {
return true; return true;
@@ -326,7 +338,8 @@ export async function updateSiteResource(
authDaemonMode, authDaemonMode,
pamMode, pamMode,
domainId, domainId,
subdomain subdomain,
aiProviderId
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array // Backward compatibility: merge deprecated siteId into siteIds array
@@ -504,7 +517,11 @@ export async function updateSiteResource(
and( and(
eq(siteResources.orgId, existingSiteResource.orgId), eq(siteResources.orgId, existingSiteResource.orgId),
eq(siteResources.alias, alias.trim()), eq(siteResources.alias, alias.trim()),
ne(siteResources.siteResourceId, siteResourceId) // exclude self ne(siteResources.siteResourceId, siteResourceId), // exclude self
ne(
siteResources.requiresExitNodeConnection,
mode == "inference"
) // exclude looking at the ones on exit nodes if this is an inference resource
) )
) )
.limit(1); .limit(1);
@@ -579,13 +596,15 @@ export async function updateSiteResource(
disableIcmp: disableIcmp:
mode !== undefined mode !== undefined
? disableIcmp || ? disableIcmp ||
(mode == "http" || mode == "ssh" (mode == "http" || mode == "ssh" ? true : false)
? true
: false)
: disableIcmp, : disableIcmp,
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
networkId: mode === "inference" ? null : undefined,
requiresExitNodeConnection:
mode !== undefined ? mode === "inference" : undefined,
aiProviderId: aiProviderId,
...sshPamSet ...sshPamSet
}) })
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(and(eq(siteResources.siteResourceId, siteResourceId)))
@@ -593,7 +612,20 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
if (siteIds !== undefined) { if (mode === "inference") {
// inference resources are not attached to any site network
if (existingSiteResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
existingSiteResource.networkId
)
);
}
updatedSiteIds = [];
} else if (siteIds !== undefined) {
// delete the site - site resources associations // delete the site - site resources associations
await trx await trx
.delete(siteNetworks) .delete(siteNetworks)
+164 -32
View File
@@ -6,7 +6,14 @@ import {
TargetHealthCheck, TargetHealthCheck,
targetHealthCheck targetHealthCheck
} from "@server/db"; } from "@server/db";
import { newts, resources, sites, Target, targets } from "@server/db"; import {
aiProviders,
newts,
resources,
sites,
Target,
targets
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -29,10 +36,19 @@ import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const createTargetParamsSchema = z.strictObject({ const resourceTargetParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
}); });
const providerTargetParamsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const createTargetParamsSchema = z.union([
resourceTargetParamsSchema,
providerTargetParamsSchema
]);
const createTargetSchema = z const createTargetSchema = z
.strictObject({ .strictObject({
siteId: z.int().positive(), siteId: z.int().positive(),
@@ -95,7 +111,7 @@ registry.registerPath({
description: "Create a target for a resource.", description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResourceLegacy], tags: [OpenAPITags.PublicResourceLegacy],
request: { request: {
params: createTargetParamsSchema, params: resourceTargetParamsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
@@ -128,7 +144,40 @@ registry.registerPath({
description: "Create a target for a resource.", description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target], tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
request: { request: {
params: createTargetParamsSchema, params: resourceTargetParamsSchema,
body: {
content: {
"application/json": {
schema: createTargetSchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "put",
path: "/ai-provider/{providerId}/target",
description: "Create a target for an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: providerTargetParamsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
@@ -183,21 +232,74 @@ export async function createTarget(
); );
} }
const { resourceId } = parsedParams.data; let resource: typeof resources.$inferSelect | undefined;
let provider: typeof aiProviders.$inferSelect | undefined;
// get the resource if ("providerId" in parsedParams.data) {
const [resource] = await db const { providerId } = parsedParams.data;
.select() [provider] =
.from(resources) req.aiProvider && req.aiProvider.providerId === providerId
.where(eq(resources.resourceId, resourceId)); ? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!resource) { if (!provider) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found` `AI provider with ID ${providerId} not found`
) )
); );
}
if (provider.routingMode !== "target") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider must use target routing mode"
)
);
}
if (provider.type !== "custom") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Only custom AI providers support targets"
)
);
}
if (
targetData.method &&
!["http", "https"].includes(targetData.method.toLowerCase())
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider target method must be http or https"
)
);
}
} else {
const { resourceId } = parsedParams.data;
[resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
} }
const siteId = targetData.siteId; const siteId = targetData.siteId;
@@ -217,6 +319,24 @@ export async function createTarget(
); );
} }
if (provider && site.orgId && site.orgId !== provider.orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site must belong to the AI provider organization"
)
);
}
const resourceId = resource?.resourceId ?? null;
const providerId = provider?.providerId ?? null;
const targetMode = provider
? "http"
: (targetData.mode ?? resource?.mode ?? "http");
const targetMethod = provider
? (targetData.method?.toLowerCase() ?? "https")
: targetData.method;
const plainToken = generateId(48); const plainToken = generateId(48);
const encryptedToken = encrypt( const encryptedToken = encrypt(
plainToken, plainToken,
@@ -230,20 +350,24 @@ export async function createTarget(
const existingTargets = await trx const existingTargets = await trx
.select() .select()
.from(targets) .from(targets)
.where(eq(targets.resourceId, resourceId)); .where(
providerId
? eq(targets.providerId, providerId)
: eq(targets.resourceId, resourceId!)
);
const existingTarget = existingTargets.find( const existingTarget = existingTargets.find(
(target) => (target) =>
target.ip === targetData.ip && target.ip === targetData.ip &&
target.port === targetData.port && target.port === targetData.port &&
target.method === targetData.method && target.method === targetMethod &&
target.siteId === targetData.siteId target.siteId === targetData.siteId
); );
if (existingTarget) { if (existingTarget) {
// log a warning // log a warning
logger.warn( logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetMethod} already exists for ${providerId ? `AI provider ID ${providerId}` : `resource ID ${resourceId}`}`
); );
} }
@@ -252,10 +376,10 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
providerId,
...targetData, ...targetData,
mode: (targetData.mode ?? mode: targetMode as Target["mode"],
resource.mode ?? method: targetMethod,
"http") as Target["mode"],
priority: targetData.priority || 100 priority: targetData.priority || 100
}) })
.returning(); .returning();
@@ -263,7 +387,7 @@ export async function createTarget(
// make sure the target is within the site subnet // make sure the target is within the site subnet
if ( if (
site.type == "wireguard" && site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!) !isIpInCidr(targetData.ip, site.exitNodeSubnet!)
) { ) {
return next( return next(
createHttpError( createHttpError(
@@ -289,13 +413,12 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
providerId,
siteId: site.siteId, siteId: site.siteId,
ip: targetData.ip, ip: targetData.ip,
mode: (targetData.mode ?? mode: targetMode as Target["mode"],
resource.mode ??
"http") as Target["mode"],
authToken: encryptedToken, authToken: encryptedToken,
method: targetData.method, method: targetMethod,
port: targetData.port, port: targetData.port,
internalPort, internalPort,
enabled: targetData.enabled, enabled: targetData.enabled,
@@ -321,10 +444,12 @@ export async function createTarget(
healthCheck = await trx healthCheck = await trx
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
orgId: resource.orgId, orgId: provider?.orgId ?? resource!.orgId,
targetId: newTarget[0].targetId, targetId: newTarget[0].targetId,
siteId: targetData.siteId, siteId: targetData.siteId,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, name: provider
? `AI Provider ${provider.name} - ${targetData.ip}:${targetData.port}`
: `Resource ${resource!.name} - ${targetData.ip}:${targetData.port}`,
hcEnabled: targetData.hcEnabled ?? false, hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null, hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null, hcScheme: targetData.hcScheme ?? null,
@@ -399,10 +524,17 @@ export async function createTarget(
newt.newtId, newt.newtId,
newTarget, newTarget,
healthCheck, healthCheck,
resource.mode === "udp" ? "udp" : "tcp", provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { } else if (
!provider &&
["ssh", "rdp", "vnc"].includes(newTarget[0].mode)
) {
await sendBrowserGatewayTargets( await sendBrowserGatewayTargets(
newt.newtId, newt.newtId,
newTarget, newTarget,
+53 -27
View File
@@ -79,38 +79,54 @@ export async function deleteTarget(
) )
); );
} }
// get the resource
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, deletedTarget.resourceId!));
if (!resource) { if (
(!deletedTarget.resourceId && !deletedTarget.providerId) ||
(deletedTarget.resourceId && deletedTarget.providerId)
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${deletedTarget.resourceId} not found` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
// check if there are other targets on the resource let resource: typeof resources.$inferSelect | undefined;
const otherTargets = await db if (deletedTarget.resourceId) {
.select() [resource] = await db
.from(targets) .select()
.where( .from(resources)
and( .where(eq(resources.resourceId, deletedTarget.resourceId))
eq(targets.resourceId, resource.resourceId), .limit(1);
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) { if (!resource) {
// set the resource status return next(
await db createHttpError(
.update(resources) HttpCode.NOT_FOUND,
.set({ health: "unknown" }) `Resource with ID ${deletedTarget.resourceId} not found`
.where(eq(resources.resourceId, resource.resourceId)); )
);
}
// check if there are other targets on the resource
const otherTargets = await db
.select()
.from(targets)
.where(
and(
eq(targets.resourceId, resource.resourceId),
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) {
// set the resource status
await db
.update(resources)
.set({ health: "unknown" })
.where(eq(resources.resourceId, resource.resourceId));
}
} }
const [site] = await db const [site] = await db
@@ -137,16 +153,26 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { if (
deletedTarget.providerId ||
["http", "tcp", "udp"].includes(deletedTarget.mode)
) {
await removeTargets( await removeTargets(
newt.newtId, newt.newtId,
// [deletedTarget], // [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck], [deletedHealthCheck],
resource.mode === "udp" ? "udp" : "tcp", deletedTarget.providerId
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { } else if (
!deletedTarget.providerId &&
["ssh", "rdp", "vnc"].includes(deletedTarget.mode)
) {
await removeBrowserGatewayTarget( await removeBrowserGatewayTarget(
newt.newtId, newt.newtId,
deletedTarget.targetId, deletedTarget.targetId,
+53 -8
View File
@@ -10,10 +10,19 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const listTargetsParamsSchema = z.strictObject({ const resourceTargetsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
}); });
const providerTargetsParamsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listTargetsParamsSchema = z.union([
resourceTargetsParamsSchema,
providerTargetsParamsSchema
]);
const listTargetsSchema = z.strictObject({ const listTargetsSchema = z.strictObject({
limit: z limit: z
.string() .string()
@@ -29,7 +38,7 @@ const listTargetsSchema = z.strictObject({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
function queryTargets(resourceId: number) { function queryTargets(owner: { resourceId: number } | { providerId: number }) {
const baseQuery = db const baseQuery = db
.select({ .select({
targetId: targets.targetId, targetId: targets.targetId,
@@ -39,6 +48,7 @@ function queryTargets(resourceId: number) {
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,
resourceId: targets.resourceId, resourceId: targets.resourceId,
providerId: targets.providerId,
siteId: targets.siteId, siteId: targets.siteId,
siteType: sites.type, siteType: sites.type,
siteName: sites.name, siteName: sites.name,
@@ -71,7 +81,11 @@ function queryTargets(resourceId: number) {
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
) )
.where(eq(targets.resourceId, resourceId)); .where(
"providerId" in owner
? eq(targets.providerId, owner.providerId)
: eq(targets.resourceId, owner.resourceId)
);
return baseQuery; return baseQuery;
} }
@@ -94,7 +108,7 @@ registry.registerPath({
description: "List targets for a resource.", description: "List targets for a resource.",
tags: [OpenAPITags.PublicResourceLegacy], tags: [OpenAPITags.PublicResourceLegacy],
request: { request: {
params: listTargetsParamsSchema, params: resourceTargetsParamsSchema,
query: listTargetsSchema query: listTargetsSchema
}, },
responses: { responses: {
@@ -121,7 +135,34 @@ registry.registerPath({
description: "List targets for a resource.", description: "List targets for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target], tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
request: { request: {
params: listTargetsParamsSchema, params: resourceTargetsParamsSchema,
query: listTargetsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/targets",
description: "List targets for an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: providerTargetsParamsSchema,
query: listTargetsSchema query: listTargetsSchema
}, },
responses: { responses: {
@@ -168,14 +209,18 @@ export async function listTargets(
) )
); );
} }
const { resourceId } = parsedParams.data; const owner = parsedParams.data;
const ownerCondition =
"providerId" in owner
? eq(targets.providerId, owner.providerId)
: eq(targets.resourceId, owner.resourceId);
const baseQuery = queryTargets(resourceId); const baseQuery = queryTargets(owner);
const countQuery = db const countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` }) .select({ count: sql<number>`cast(count(*) as integer)` })
.from(targets) .from(targets)
.where(eq(targets.resourceId, resourceId)); .where(ownerCondition);
const targetsList = await baseQuery.limit(limit).offset(offset); const targetsList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
+90 -16
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db"; import { aiProviders, newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -147,21 +147,68 @@ export async function updateTarget(
); );
} }
// get the resource if (
const [resource] = await db (!target.resourceId && !target.providerId) ||
.select() (target.resourceId && target.providerId)
.from(resources) ) {
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${target.resourceId} not found` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
let resource: typeof resources.$inferSelect | undefined;
let provider: typeof aiProviders.$inferSelect | undefined;
if (target.resourceId) {
[resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${target.resourceId} not found`
)
);
}
} else {
[provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, target.providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${target.providerId} not found`
)
);
}
if (
parsedBody.data.method !== undefined &&
(!parsedBody.data.method ||
!["http", "https"].includes(
parsedBody.data.method.toLowerCase()
))
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider target method must be http or https"
)
);
}
}
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
@@ -177,6 +224,15 @@ export async function updateTarget(
); );
} }
if (provider && site.orgId && site.orgId !== provider.orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site must belong to the AI provider organization"
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!, db); const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) { if (!internalPort) {
@@ -221,8 +277,13 @@ export async function updateTarget(
} }
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const nextMode = const nextMode = provider
parsedBody.data.mode === null ? undefined : parsedBody.data.mode; ? parsedBody.data.mode !== undefined
? "http"
: undefined
: parsedBody.data.mode === null
? undefined
: parsedBody.data.mode;
let updatedTarget: any; let updatedTarget: any;
let updatedHc: any; let updatedHc: any;
@@ -233,7 +294,10 @@ export async function updateTarget(
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip, ip: parsedBody.data.ip,
mode: nextMode, mode: nextMode,
method: parsedBody.data.method, method:
provider && parsedBody.data.method
? parsedBody.data.method.toLowerCase()
: parsedBody.data.method,
port: parsedBody.data.port, port: parsedBody.data.port,
internalPort, internalPort,
enabled: parsedBody.data.enabled, enabled: parsedBody.data.enabled,
@@ -368,15 +432,25 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { if (
provider ||
["http", "tcp", "udp"].includes(updatedTarget.mode)
) {
await addTargets( await addTargets(
newt.newtId, newt.newtId,
[updatedTarget], [updatedTarget],
[updatedHc], [updatedHc],
resource.mode === "udp" ? "udp" : "tcp", provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { } else if (
!provider &&
["ssh", "rdp", "vnc"].includes(updatedTarget.mode)
) {
await sendBrowserGatewayTargets( await sendBrowserGatewayTargets(
newt.newtId, newt.newtId,
[updatedTarget], [updatedTarget],
@@ -20,6 +20,9 @@ export async function traefikConfigProvider(
const maintenancePort = config.getRawConfig().server.next_port; const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost = config.getRawConfig().server.internal_hostname; const maintenanceHost = config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl = `http://${maintenanceHost}:${
config.getRawConfig().server.internal_port
}/api/v1/ai-gateway`;
const traefikConfig = await getTraefikConfig( const traefikConfig = await getTraefikConfig(
currentExitNodeId, currentExitNodeId,
@@ -28,7 +31,8 @@ export async function traefikConfigProvider(
build != "oss", // generate the login pages on the cloud and and enterprise, build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources, config.getRawConfig().traefik.allow_raw_resources,
pangolinUIUrl, pangolinUIUrl,
pangolinUIUrl pangolinUIUrl,
aiGatewayUrl
); );
if (traefikConfig?.http?.middlewares) { if (traefikConfig?.http?.middlewares) {
+5 -3
View File
@@ -5,7 +5,7 @@ import {
handleNewtGetConfigMessage, handleNewtGetConfigMessage,
handleDockerStatusMessage, handleDockerStatusMessage,
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage, handleNewtExitNodesRequestMessage,
handleApplyBlueprintMessage, handleApplyBlueprintMessage,
handleNewtPingMessage, handleNewtPingMessage,
startNewtOfflineChecker, startNewtOfflineChecker,
@@ -22,7 +22,8 @@ import {
handleOlmDisconnectingMessage, handleOlmDisconnectingMessage,
handleOlmServerInitAddPeerHandshake, handleOlmServerInitAddPeerHandshake,
handleOlmLocalMessage, handleOlmLocalMessage,
handleOlmUnLocalMessage handleOlmUnLocalMessage,
handleOlmExitNodesRequestMessage
} from "../olm"; } from "../olm";
import { handleHealthcheckStatusMessage } from "../target"; import { handleHealthcheckStatusMessage } from "../target";
import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { handleRoundTripMessage } from "./handleRoundTripMessage";
@@ -37,6 +38,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/local": handleOlmLocalMessage, "olm/wg/local": handleOlmLocalMessage,
"olm/wg/unlocal": handleOlmUnLocalMessage, "olm/wg/unlocal": handleOlmUnLocalMessage,
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"olm/ping/request": handleOlmExitNodesRequestMessage,
"olm/disconnecting": handleOlmDisconnectingMessage, "olm/disconnecting": handleOlmDisconnectingMessage,
"newt/disconnecting": handleNewtDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage,
"newt/ping": handleNewtPingMessage, "newt/ping": handleNewtPingMessage,
@@ -45,7 +47,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage,
"newt/socket/status": handleDockerStatusMessage, "newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage, "newt/socket/containers": handleDockerContainersMessage,
"newt/ping/request": handleNewtPingRequestMessage, "newt/ping/request": handleNewtExitNodesRequestMessage,
"newt/blueprint/apply": handleApplyBlueprintMessage, "newt/blueprint/apply": handleApplyBlueprintMessage,
"newt/healthcheck/status": handleHealthcheckStatusMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage,
"ws/round-trip/complete": handleRoundTripMessage "ws/round-trip/complete": handleRoundTripMessage
@@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
archived: Boolean(client.archived), archived: Boolean(client.archived),
blocked: Boolean(client.blocked), blocked: Boolean(client.blocked),
approvalState: client.approvalState, approvalState: client.approvalState,
fingerprint fingerprint,
firstSeen: client.firstSeen ?? null,
lastSeen: client.lastSeen ?? null
}; };
}; };
@@ -0,0 +1,107 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceInferencePage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
mode: "inference",
alias: siteResource.alias ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
mode: "inference",
alias: data.alias
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-host-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<PrivateResourceInferenceDestinationFields
control={asAnyControl(form.control)}
watch={asAnyWatch(form.watch)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-host-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -52,7 +52,8 @@ export default async function PrivateResourceLayout(
| "hostSettings" | "hostSettings"
| "cidrSettings" | "cidrSettings"
| "httpSettings" | "httpSettings"
| "sshSettings"; | "sshSettings"
| "inferenceSettings";
const navItems = [ const navItems = [
{ {
@@ -1,5 +1,5 @@
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
@@ -61,7 +61,7 @@ export default async function ClientResourcesPage(
redirect(`/${params.orgId}/settings/resources`); redirect(`/${params.orgId}/settings/resources`);
} }
const internalResourceRows: InternalResourceRow[] = siteResources.map( const internalResourceRows: PrivateResourceRow[] = siteResources.map(
(siteResource) => { (siteResource) => {
return { return {
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
@@ -596,6 +596,7 @@ export function ProxyResourceTargetsForm({
priority: 100, priority: 100,
enabled: true, enabled: true,
resourceId: resource?.resourceId ?? 0, resourceId: resource?.resourceId ?? 0,
providerId: null,
hcEnabled: false, hcEnabled: false,
hcPath: null, hcPath: null,
hcMethod: null, hcMethod: null,
@@ -206,7 +206,7 @@ function createAddTargetSchema(t: TranslateFn) {
); );
} }
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
type CreateBgTargetFormValues = SshSettingsFormValues; type CreateBgTargetFormValues = SshSettingsFormValues;
@@ -72,8 +72,7 @@ export default function CredentialsPage() {
const { data: latestVersions } = useQuery( const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true) productUpdatesQueries.latestVersion(true)
); );
const newtVersion = const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
latestVersions?.data?.newt?.latestVersion ?? "latest";
// Fetch site defaults for wireguard sites to show in obfuscated config // Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => { useEffect(() => {
@@ -354,7 +353,7 @@ export default function CredentialsPage() {
text={generateObfuscatedWireGuardConfig( text={generateObfuscatedWireGuardConfig(
{ {
subnet: subnet:
site?.subnet || site?.exitNodeSubnet ||
siteDefaults?.subnet || siteDefaults?.subnet ||
null, null,
address: address:
+5 -4
View File
@@ -91,6 +91,7 @@ export default async function Page(props: {
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null = let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null; null;
if (!useSmartLogin) { if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") { if (build === "oss" || env.app.identityProviderMode !== "org") {
@@ -117,12 +118,12 @@ export default async function Page(props: {
`/idp/${persistedData.idpId}` `/idp/${persistedData.idpId}`
); );
const idp = idpRes.data.data.idp; const res = idpRes.data.data;
lastUsedIdpForSmartLogin = { lastUsedIdpForSmartLogin = {
idpId: idp.idpId, idpId: res.idp.idpId,
name: idp.name, name: res.idp.name,
variant: idp.type, variant: res.idpOidcConfig?.variant ?? res.idp.type,
orgId: persistedData.orgId, orgId: persistedData.orgId,
lastUsed: true lastUsed: true
}; };
+41 -48
View File
@@ -114,59 +114,52 @@ export default function IdpLoginButtons({
<div className="space-y-4"> <div className="space-y-4">
{params.get("gotoapp") ? ( {params.get("gotoapp") ? (
<> <Button
<Button type="button"
type="button" className="w-full"
className="w-full" onClick={() => {
onClick={() => { goToApp();
goToApp(); }}
}} >
> {t("continueToApplication")}
{t("continueToApplication")} </Button>
</Button>
</>
) : ( ) : (
<> idps.map((idp) => {
{idps.map((idp) => { const effectiveType =
const effectiveType = idp.variant || idp.name.toLowerCase();
idp.variant || idp.name.toLowerCase();
return ( return (
<div <div className="w-full relative" key={idp.idpId}>
className="w-full relative" <Button
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
> >
<Button <IdpTypeIcon
key={idp.idpId} type={effectiveType}
type="button" size={16}
variant="outline" />
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10" <span>{idp.name}</span>
onClick={() => { </Button>
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{idp.lastUsed && ( {idp.lastUsed && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5"> <span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")} {t("idpLastUsed")}
</span> </span>
</div> </div>
)} )}
</div> </div>
); );
})} })
</>
)} )}
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More