mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-02 18:50:39 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9ede9729 | |||
| 5daf334e32 | |||
| cb421f5c41 | |||
| 28430dde74 | |||
| 573747c237 | |||
| 9e10428640 | |||
| 104dcebda7 | |||
| 7e05c16ccd | |||
| a6b97ea130 | |||
| 1333c8e841 | |||
| f1a2da277e | |||
| 3d34ed70b0 | |||
| df7bb2fb57 | |||
| 4d7a4c809d | |||
| 82d1d29844 | |||
| 117df40e68 | |||
| 2e8576bdb0 | |||
| 2584473898 | |||
| 0d56503ace | |||
| 972e16d0c5 | |||
| c535abf832 | |||
| 59ea701304 | |||
| 7d7c54107d | |||
| f0f6673d69 | |||
| 71561d0e65 | |||
| af87edf3a6 | |||
| 13caad18c7 | |||
| 47522b7e3a | |||
| c8c8d74452 | |||
| e7098963d6 | |||
| f015fb592b | |||
| c099167905 | |||
| b0e274f5a9 | |||
| e0a8721207 |
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[]>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./chatCompletions";
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+12
-28
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user