Compare commits

...

31 Commits

Author SHA1 Message Date
miloschwartz 28430dde74 add targets and refactor endpoints 2026-07-31 17:58:23 -04:00
Owen 573747c237 Add basic page to configure the alias 2026-07-31 17:47:45 -04:00
Owen 9e10428640 Handle the alias create and update special case exit nodes 2026-07-31 17:22:08 -04:00
Owen 104dcebda7 Fix types 2026-07-31 16:50:35 -04:00
Owen 7e05c16ccd Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-07-31 16:30:30 -04:00
Owen a6b97ea130 Handle aliases when registering 2026-07-31 16:21:55 -04:00
miloschwartz 1333c8e841 add default providers and support overrides 2026-07-31 15:55:10 -04:00
miloschwartz f1a2da277e add crud for providers and models 2026-07-31 15:44:17 -04:00
miloschwartz 3d34ed70b0 add basic schema 2026-07-31 15:23:19 -04:00
Owen df7bb2fb57 Get exit node every time 2026-07-31 15:17:16 -04:00
Owen 4d7a4c809d sync the exit node connection 2026-07-31 14:23:51 -04:00
Owen 82d1d29844 Update site resources to handle new inference 2026-07-31 14:23:51 -04:00
Owen 117df40e68 send if we need to connect to the gerbil or not 2026-07-31 14:23:51 -04:00
Owen 2e8576bdb0 Add inference resource type 2026-07-31 14:23:51 -04:00
Owen 2584473898 Handle the ping exit node request backward mode for olm and install handler 2026-07-31 14:23:51 -04:00
Owen 0d56503ace Fix postgres schema exit node subnet 2026-07-31 14:23:51 -04:00
Owen 972e16d0c5 Rename subnet for clarity, pick subnet on client 2026-07-31 14:23:51 -04:00
Owen c535abf832 Add exit node selection to the clients 2026-07-31 14:23:51 -04:00
Owen 59ea701304 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-31 14:23:43 -04:00
miloschwartz 7d7c54107d add first seen and last seen columns to user devices table 2026-07-31 14:23:06 -04:00
Owen Schwartz f0f6673d69 Merge pull request #3511 from fosrl/feat/show-idp-icon-in-smart-login-form
feat: show the correct idp type icon in the last used idp
2026-07-31 09:32:01 -04:00
Owen Schwartz 71561d0e65 Merge pull request #3512 from fosrl/fix/batch-status-hover-data
Fix: resolve batch certificates for NS & Wildcard domains
2026-07-31 09:31:21 -04:00
Fred KISSIE af87edf3a6 ♻️ use const 2026-07-30 22:06:38 +01:00
Fred KISSIE 13caad18c7 🎨 format file 2026-07-30 21:53:46 +01:00
Fred KISSIE 47522b7e3a ♻️ set cert error if initial value is null 2026-07-30 21:53:31 +01:00
Fred KISSIE c8c8d74452 🐛 resolve certificates for non exact domains too 2026-07-30 21:52:51 +01:00
Fred KISSIE e7098963d6 ♻️ Code Refactoring 2026-07-30 17:56:15 +01:00
Fred KISSIE f015fb592b ♻️ Prioritize variant in place of idp.type if available 2026-07-30 17:55:58 +01:00
Fred KISSIE c099167905 Merge branch 'dev' into feat/show-idp-icon-in-smart-login-form 2026-07-30 17:37:47 +01:00
Owen b0e274f5a9 Fix 100% showing on resources with no hc anymore 2026-07-30 11:14:34 -04:00
Fred KISSIE e0a8721207 🚧 wip: idp type icon in last used idp 2026-07-29 22:29:56 +01:00
96 changed files with 4045 additions and 583 deletions
+12
View File
@@ -1424,6 +1424,16 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionCreateAiProvider": "Create AI Provider",
"actionDeleteAiProvider": "Delete AI Provider",
"actionGetAiProvider": "Get AI Provider",
"actionListAiProviders": "List AI Providers",
"actionUpdateAiProvider": "Update AI Provider",
"actionCreateAiModel": "Create AI Model",
"actionDeleteAiModel": "Delete AI Model",
"actionGetAiModel": "Get AI Model",
"actionListAiModels": "List AI Models",
"actionUpdateAiModel": "Update AI Model",
"actionApplyBlueprint": "Apply Blueprint", "actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints", "actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint", "actionGetBlueprint": "Get Blueprint",
@@ -2246,6 +2256,7 @@
"requireDeviceApproval": "Require Device Approvals", "requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings", "sshSettings": "SSH Settings",
"inferenceSettings": "Inference Settings",
"sshAccess": "SSH Access", "sshAccess": "SSH Access",
"rdpSettings": "RDP Settings", "rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings", "vncSettings": "VNC Settings",
@@ -2401,6 +2412,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeInference": "Inference",
"editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme", "editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS", "editInternalResourceDialogEnableSsl": "Enable TLS",
+11 -1
View File
@@ -182,7 +182,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(
+81 -7
View File
@@ -99,6 +99,7 @@ export const sites = pgTable(
name: varchar("name").notNull(), name: varchar("name").notNull(),
pubKey: varchar("pubKey"), pubKey: varchar("pubKey"),
subnet: varchar("subnet"), subnet: varchar("subnet"),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: real("bytesIn").default(0), megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0), megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -194,7 +195,12 @@ export const resources = pgTable(
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false), wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc mode: text("mode")
.default("http")
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(),
pamMode: varchar("pamMode", { length: 32 }) pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
.default("passthrough"), .default("passthrough"),
@@ -317,11 +323,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 +358,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 +438,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
@@ -1181,7 +1198,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 +1557,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 +1696,5 @@ 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>;
+80 -8
View File
@@ -4,6 +4,7 @@ import {
index, index,
integer, integer,
primaryKey, primaryKey,
real,
sqliteTable, sqliteTable,
text, text,
unique unique
@@ -107,7 +108,7 @@ export const sites = sqliteTable("sites", {
}), }),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0), megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0), megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -203,7 +204,10 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode") pamMode: text("pamMode")
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
.default("passthrough"), .default("passthrough"),
@@ -322,11 +326,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 +427,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
@@ -599,6 +611,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 +1539,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 +1678,5 @@ 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>;
+4
View File
@@ -9,6 +9,8 @@ import { createIntegrationApiServer } from "./integrationApiServer";
import { import {
ApiKey, ApiKey,
ApiKeyOrg, ApiKeyOrg,
AiModel,
AiProvider,
RemoteExitNode, RemoteExitNode,
Session, Session,
SiteResource, SiteResource,
@@ -83,6 +85,8 @@ declare global {
userOrgIds?: string[]; userOrgIds?: string[];
remoteExitNode?: RemoteExitNode; remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource; siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
orgPolicyAllowed?: boolean; orgPolicyAllowed?: boolean;
} }
} }
+106
View File
@@ -0,0 +1,106 @@
export type AiProviderType =
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom";
export type AiProviderAuthType = "bearer";
export type AiBudgetUnit = "usd" | "tokens";
export type AiProviderRoutingMode = "url" | "target";
type AiProviderDefaults = {
upstreamUrl: string | null;
authType: AiProviderAuthType;
};
export const AI_PROVIDER_DEFAULTS: Record<
Exclude<AiProviderType, "custom">,
AiProviderDefaults
> = {
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer"
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "bearer"
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
authType: "bearer"
},
vertexAi: {
upstreamUrl: null,
authType: "bearer"
},
bedrock: {
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
authType: "bearer"
},
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer"
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
authType: "bearer"
},
vercelAiGateway: {
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
authType: "bearer"
}
};
export function providerRequiresUpstreamUrl(
type: AiProviderType,
routingMode: AiProviderRoutingMode = "url"
): boolean {
if (routingMode === "target") {
return false;
}
if (type === "custom") {
return true;
}
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
}
export function resolveAiProviderConfig(input: {
type: AiProviderType;
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
routingMode: AiProviderRoutingMode;
} {
const routingMode = input.routingMode ?? "url";
if (routingMode === "target") {
return {
upstreamUrl: null,
authType: input.authType ?? "bearer",
routingMode
};
}
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl,
authType: input.authType,
routingMode
};
}
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
routingMode
};
}
@@ -202,6 +202,10 @@ async function handleResource(
return; return;
} }
if (!target.resourceId) {
return;
}
const [resource] = await trx const [resource] = await trx
.select() .select()
.from(resources) .from(resources)
@@ -227,9 +231,7 @@ async function handleResource(
let health = "healthy"; let health = "healthy";
const allUnknown = monitoredTargets.length === 0; const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every( const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
(t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every( const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy" (t) => t.hcHealth === "unhealthy"
); );
-14
View File
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
continue; continue;
} }
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet // Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } = const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx); await getNextAvailableClientSubnet(orgId, trx);
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,
orgId: userOrg.orgId, orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client", name: olm.name || "User Client",
subnet: updatedSubnet, subnet: updatedSubnet,
olmId: olm.olmId, olmId: olm.olmId,
+8 -1
View File
@@ -64,13 +64,20 @@ export async function performDeleteResources(
const targetsByResourceId = new Map<number, Target[]>(); const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) { for (const target of targetsToBeRemoved) {
if (target.resourceId == null) {
continue;
}
const existing = targetsByResourceId.get(target.resourceId) ?? []; const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target); existing.push(target);
targetsByResourceId.set(target.resourceId, existing); targetsByResourceId.set(target.resourceId, existing);
} }
const targetIdToResourceId = new Map( const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) targetsToBeRemoved.flatMap((target) =>
target.resourceId == null
? []
: [[target.targetId, target.resourceId] as const]
)
); );
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>(); const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
+5 -3
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { import {
db, db,
resources, resources,
@@ -33,9 +33,11 @@ export async function getResourceIdsForSite(
const rows = await trx const rows = await trx
.selectDistinct({ resourceId: targets.resourceId }) .selectDistinct({ resourceId: targets.resourceId })
.from(targets) .from(targets)
.where(eq(targets.siteId, siteId)); .where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
return rows.map((row) => row.resourceId); return rows
.map((row) => row.resourceId)
.filter((resourceId): resourceId is number => resourceId != null);
} }
export async function getSiteResourceIdsForSite( export async function getSiteResourceIdsForSite(
@@ -0,0 +1,40 @@
import { db, sites, clients } from "@server/db";
import { and, eq, count } from "drizzle-orm";
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
// higher = more desirable
// like saying, this node has x% of its capacity left
export async function calculateExitNodeWeight(
exitNodeId: number,
maxConnections: number | null | undefined
): Promise<number | null> {
if (maxConnections === null || maxConnections === undefined) {
return 1;
}
const [[siteConnections], [clientConnections]] = await Promise.all([
db
.select({ count: count() })
.from(sites)
.where(
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
),
db
.select({ count: count() })
.from(clients)
.where(
and(
eq(clients.exitNodeId, exitNodeId),
eq(clients.online, true)
)
)
]);
const currentConnections = siteConnections.count + clientConnections.count;
if (currentConnections >= maxConnections) {
return null;
}
return (maxConnections - currentConnections) / maxConnections;
}
+10 -1
View File
@@ -1,6 +1,5 @@
import { db, exitNodes, Transaction } from "@server/db"; import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export async function verifyExitNodeOrgAccess( export async function verifyExitNodeOrgAccess(
@@ -52,6 +51,16 @@ export async function listExitNodes(
return allExitNodes; return allExitNodes;
} }
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export function selectBestExitNode( export function selectBestExitNode(
pingResults: ExitNodePingResult[] pingResults: ExitNodePingResult[]
): ExitNodePingResult | null { ): ExitNodePingResult | null {
@@ -0,0 +1,41 @@
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getUniqueSubnetForExitNode(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const [sitesQuery, clientsQuery] = await Promise.all([
trx
.select({ subnet: sites.exitNodeSubnet })
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
trx
.select({ subnet: clients.exitNodeSubnet })
.from(clients)
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
]);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = [...sitesQuery, ...clientsQuery]
.map((row) => row.subnet)
.filter(
(subnet): subnet is string =>
!!subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from "./exitNodes";
export * from "./exitNodeComms"; export * from "./exitNodeComms";
export * from "./subnet"; export * from "./subnet";
export * from "./getCurrentExitNodeId"; export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight";
export * from "./getUniqueSubnetForExitNode";
+21 -8
View File
@@ -528,7 +528,10 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null }; export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(
allSiteResources: SiteResource[],
overrideIp?: string
): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter(
(sr) => (sr) =>
@@ -539,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
) )
.map((sr) => ({ .map((sr) => ({
alias: sr.alias || sr.fullDomain, alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress aliasAddress: overrideIp || sr.aliasAddress
})); }));
} }
@@ -660,9 +663,10 @@ export type CertRef = { id: string; cert: string; key: string };
* certificate (e.g. a wildcard cert used by thousands of site resources) * certificate (e.g. a wildcard cert used by thousands of site resources)
* only need that certificate sent once per sync message. * only need that certificate sent once per sync message.
*/ */
export function dedupeCertsForTargets( export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
targetsV2: SubnetProxyTargetV2[] targets: SubnetProxyTargetV2[];
): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } { certs: CertRef[];
} {
const idByContent = new Map<string, string>(); const idByContent = new Map<string, string>();
const certs: CertRef[] = []; const certs: CertRef[] = [];
@@ -674,7 +678,10 @@ export function dedupeCertsForTargets(
const contentKey = `${target.tlsCert}|${target.tlsKey}`; const contentKey = `${target.tlsCert}|${target.tlsKey}`;
let id = idByContent.get(contentKey); let id = idByContent.get(contentKey);
if (!id) { if (!id) {
id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16); id = createHash("sha1")
.update(contentKey)
.digest("hex")
.slice(0, 16);
idByContent.set(contentKey, id); idByContent.set(contentKey, id);
certs.push({ id, cert: target.tlsCert, key: target.tlsKey }); certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
} }
@@ -708,7 +715,9 @@ export async function batchFetchCertsForSiteResources(
): Promise<CertByDomain> { ): Promise<CertByDomain> {
const domains = new Set( const domains = new Set(
allSiteResources allSiteResources
.filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain) .filter(
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
)
.map((r) => r.fullDomain as string) .map((r) => r.fullDomain as string)
); );
@@ -852,7 +861,11 @@ export async function generateSubnetProxyTargetV2(
new Set([siteResource.fullDomain]), new Set([siteResource.fullDomain]),
true true
); );
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { if (
certs.length > 0 &&
certs[0].certFile &&
certs[0].keyFile
) {
tlsCert = certs[0].certFile; tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile; tlsKey = certs[0].keyFile;
} else { } else {
+296 -6
View File
@@ -19,7 +19,7 @@ import {
userOrgRoles, userOrgRoles,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm"; import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import { import {
@@ -27,6 +27,9 @@ import {
deletePeersBatch as olmDeletePeersBatch deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers"; } from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { sendToClientsBatch } from "#dynamic/routers/ws";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
generateAliasConfig, generateAliasConfig,
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
); );
if (sitesList.length === 0) { if (sitesList.length === 0 && siteResource.networkId !== null) {
// A site resource with a networkId is expected to have at least one
// site attached via siteNetworks. Resources with no networkId (e.g.
// inference-mode resources, which connect clients directly to the
// exit node instead of any site) are expected to have no sites, so
// don't warn for those.
logger.warn( logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
); );
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove, clientSiteResourcesToRemove,
trx trx
); );
// If this resource requires clients to be connected to the exit node
// (e.g. an inference resource), re-sync the connect/disconnect state for
// every client whose access to it may have changed - both those who
// currently have access and those who just lost it.
if (siteResource.requiresExitNodeConnection) {
await syncClientExitNodeConnections(
Array.from(
new Set([
...mergedAllClientIds,
...existingClientSiteResourceIds
])
),
trx
);
}
} }
async function handleMessagesForSiteClients( async function handleMessagesForSiteClients(
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.exitNodeSubnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
sourcePort: parsedEndpoint.port, sourcePort: parsedEndpoint.port,
destinations: [ destinations: [
{ {
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
] ]
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
} else { } else {
// add to the existing destinations // add to the existing destinations
destinations.destinations.push({ destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0], destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
@@ -1052,6 +1076,236 @@ export async function updateClientSiteDestinations(
} }
} }
// Determines, for each of the given clients, whether they currently have
// access to any enabled site resource with requiresExitNodeConnection set
// (e.g. an inference-mode resource) and tells the client's olm to connect to
// or disconnect from its assigned exit node accordingly. Site resources with
// requiresExitNodeConnection don't belong to any site/network, so this can't
// be derived from the per-site peer logic above - it has to be recomputed
// from the client's full current resource access every time that access
// changes.
async function syncClientExitNodeConnections(
clientIds: number[],
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
// Only clients with an exit node assigned can be told to connect/disconnect.
const clientsData = await trx
.select({
clientId: clients.clientId,
exitNodeId: clients.exitNodeId,
exitNodeSubnet: clients.exitNodeSubnet
})
.from(clients)
.where(
and(
inArray(clients.clientId, uniqueClientIds),
isNotNull(clients.exitNodeId)
)
);
if (clientsData.length === 0) {
return;
}
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
const requiresExitNodeRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
clientIdsWithExitNode
),
eq(siteResources.enabled, true),
eq(siteResources.requiresExitNodeConnection, true)
)
);
const needsConnectSet = new Set(
requiresExitNodeRows.map((r) => r.clientId)
);
const exitNodeIds = Array.from(
new Set(
clientsData
.map((c) => c.exitNodeId)
.filter((id): id is number => id !== null)
)
);
const exitNodeRows =
exitNodeIds.length > 0
? await trx
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
: [];
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, clientIdsWithExitNode));
const olmByClientId = new Map(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r])
);
const relayPort = config.getRawConfig().gerbil.clients_start_port;
const connectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean };
}[] = [];
const disconnectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean };
}[] = [];
for (const client of clientsData) {
const olm = olmByClientId.get(client.clientId);
if (!olm) {
// No olm registered for this client yet/anymore, nothing to send.
continue;
}
const needsConnect = needsConnectSet.has(client.clientId);
if (needsConnect) {
const exitNode = client.exitNodeId
? exitNodeById.get(client.exitNodeId)
: undefined;
if (!exitNode || !client.exitNodeSubnet) {
logger.warn(
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
);
continue;
}
connectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/connect",
data: {
connect: true,
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0]
}
},
options: { compress: canCompress(olm.version, "olm") }
});
} else {
disconnectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/disconnect",
data: {}
},
options: { compress: canCompress(olm.version, "olm") }
});
}
}
if (connectPayloads.length > 0) {
await sendToClientsBatch(connectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node connect messages:`,
error
);
});
}
if (disconnectPayloads.length > 0) {
await sendToClientsBatch(disconnectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
error
);
});
}
}
// Notifies the olms of every given client that the alias of the site resource
// they're using an exit node connection for has changed, via the dedicated
// exit node data-update message. Unlike syncClientExitNodeConnections, this
// doesn't touch connect/disconnect state - it's purely a rename for clients
// that are (and remain) connected to the exit node for this resource.
async function syncClientExitNodeAliasUpdate(
clientIds: number[],
oldAlias: string | null,
newAlias: string | null,
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
const oldAliases = oldAlias ? [oldAlias] : [];
const newAliases = newAlias ? [newAlias] : [];
if (oldAliases.length === 0 && newAliases.length === 0) {
return;
}
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, uniqueClientIds));
const updatePayloads = olmRows
.filter((r) => r.clientId !== null)
.map((olm) => ({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/data/update",
data: {
oldAliases,
newAliases
}
},
options: { compress: canCompress(olm.version, "olm") }
}));
if (updatePayloads.length > 0) {
await sendToClientsBatch(updatePayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node alias update messages:`,
error
);
});
}
}
async function handleSubnetProxyTargetUpdates( async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource, siteResource: SiteResource,
sitesList: Site[], sitesList: Site[],
@@ -1282,7 +1536,7 @@ export async function handleMessagingForUpdatedSiteResource(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
); );
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED // !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
const mergedAllClients = await trx const mergedAllClients = await trx
.select({ .select({
clientId: clientSiteResourcesAssociationsCache.clientId, clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -1709,6 +1963,38 @@ export async function handleMessagingForUpdatedSiteResource(
); );
} }
// For a resource that stays on an exit node connection across the update,
// the alias is the only field that affects already-connected clients (the
// exit node itself, its endpoint, etc. are not per-resource). Tell those
// clients' olms about the rename directly via the exit node data-update
// message rather than a full connect/disconnect cycle.
if (
existingSiteResource?.requiresExitNodeConnection &&
updatedSiteResource.requiresExitNodeConnection &&
aliasChanged
) {
await syncClientExitNodeAliasUpdate(
mergedAllClients.map((c) => c.clientId),
existingSiteResource.alias,
updatedSiteResource.alias,
trx
);
}
// If this resource requires (or required) clients to be connected to the
// exit node (e.g. an inference resource), re-sync connect/disconnect
// state for every client currently associated with it - covers toggling
// requiresExitNodeConnection on update as well as enabling/disabling it.
if (
updatedSiteResource.requiresExitNodeConnection ||
existingSiteResource?.requiresExitNodeConnection
) {
await syncClientExitNodeConnections(
mergedAllClients.map((c) => c.clientId),
trx
);
}
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
); );
@@ -1990,6 +2276,10 @@ async function rebuildClientAssociationsFromClientImpl(
resourcesToRemove, resourcesToRemove,
trx trx
); );
// Re-sync exit node connect/disconnect state based on this client's
// current full set of resource access (e.g. inference resources).
await syncClientExitNodeConnections([client.clientId], trx);
} }
async function handleMessagesForClientSites( async function handleMessagesForClientSites(
+1 -1
View File
@@ -87,7 +87,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,
+2
View File
@@ -27,6 +27,8 @@ export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess"; export * from "./verifyApiKeyAccess";
export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifySiteProvisioningKeyAccess";
export * from "./verifyDomainAccess"; export * from "./verifyDomainAccess";
export * from "./verifyAiProviderAccess";
export * from "./verifyAiModelAccess";
export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserIsOrgOwner";
export * from "./verifyUserFromResourceSession"; export * from "./verifyUserFromResourceSession";
export * from "./verifySiteResourceAccess"; export * from "./verifySiteResourceAccess";
+2
View File
@@ -16,5 +16,7 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyAiProviderAccess";
export * from "./verifyApiKeyAiModelAccess";
export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeyResourcePolicyAccess";
export * from "./verifyApiKeySiteProvisioningKeyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess";
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
}
const orgId = row.provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, apiKeyOrg, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
if (apiKey.isRoot) {
req.aiProvider = provider;
return next();
}
const orgId = provider.orgId;
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, targets, apiKeyOrg } from "@server/db"; import { aiProviders, resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess(
); );
} }
const resourceId = target.resourceId; const { resourceId, providerId } = target;
if (!resourceId) { if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID` `Target with ID ${targetId} has invalid ownership`
)
);
}
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
) )
); );
} }
if (apiKey.isRoot) { if (apiKey.isRoot) {
// Root keys can access any key in any org // Root keys can access any target
return next(); return next();
} }
if (!resource.orgId) { let orgId: string;
return next( if (resourceId) {
createHttpError( const [resource] = await db
HttpCode.INTERNAL_SERVER_ERROR, .select()
`Resource with ID ${resourceId} does not have an organization ID` .from(resources)
) .where(eq(resources.resourceId, resourceId))
); .limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg) {
@@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess(
.where( .where(
and( and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, resource.orgId) eq(apiKeyOrg.orgId, orgId)
) )
) )
.limit(1); .limit(1);
@@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess(
} }
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
+107
View File
@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from "express";
import { aiModels, aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiModelAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const modelIdRaw = getFirstString(req.params.modelId);
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(modelId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
);
}
const [row] = await db
.select({
model: aiModels,
provider: aiProviders
})
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
const orgId = row.provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = row.provider;
req.aiModel = row.model;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI model access"
)
);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from "express";
import { aiProviders, db, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAiProviderAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const providerIdRaw = getFirstString(req.params.providerId);
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (Number.isNaN(providerId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
);
}
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const orgId = provider.orgId;
if (!req.userOrg || req.userOrg.orgId !== orgId) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"" + (policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgId = orgId;
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.aiProvider = provider;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying AI provider access"
)
);
}
}
+71 -56
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, targets, userOrgs } from "@server/db"; import { aiProviders, resources, targets, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -25,9 +25,7 @@ export async function verifyTargetAccess(
} }
if (isNaN(targetId)) { if (isNaN(targetId)) {
return next( return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID"));
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
} }
const target = await db const target = await db
@@ -45,73 +43,88 @@ export async function verifyTargetAccess(
); );
} }
const resourceId = target[0].resourceId; const { resourceId, providerId } = target[0];
if (!resourceId) { if ((!resourceId && !providerId) || (resourceId && providerId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} does not have a resource ID` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
try { try {
const resource = await db let orgId: string;
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId!))
.limit(1);
if (resource.length === 0) { if (resourceId) {
return next( const [resource] = await db
createHttpError( .select()
HttpCode.NOT_FOUND, .from(resources)
`Resource with ID ${resourceId} not found` .where(eq(resources.resourceId, resourceId))
) .limit(1);
);
}
if (!resource[0].orgId) { if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.NOT_FOUND,
`resource with ID ${resourceId} does not have an organization ID` `Resource with ID ${resourceId} not found`
) )
); );
}
if (!resource.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID`
)
);
}
orgId = resource.orgId;
} else {
const [provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
orgId = provider.orgId;
} }
if (!req.userOrg) { if (!req.userOrg) {
const res = await db const userOrgResult = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource[0].orgId)
)
); );
req.userOrg = res[0]; req.userOrg = userOrgResult[0];
} }
if (!req.userOrg) { if (!req.userOrg || req.userOrg.orgId !== orgId) {
next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization"
) )
); );
} else {
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!;
} }
const orgId = req.userOrg.orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId;
if (req.orgPolicyAllowed === undefined && orgId) { if (req.orgPolicyAllowed === undefined) {
const policyCheck = await checkOrgAccessPolicy({ const policyCheck = await checkOrgAccessPolicy({
orgId, orgId,
userId, userId,
@@ -128,22 +141,24 @@ export async function verifyTargetAccess(
} }
} }
const resourceAllowed = await canUserAccessResource({ if (resourceId) {
userId, const resourceAllowed = await canUserAccessResource({
resourceId, userId,
roleIds: req.userOrgRoleIds ?? [] resourceId,
}); roleIds: req.userOrgRoleIds ?? []
});
if (!resourceAllowed) { if (!resourceAllowed) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this resource" "User does not have access to this resource"
) )
); );
}
} }
next(); return next();
} catch (e) { } catch (e) {
return next( return next(
createHttpError( createHttpError(
+3 -1
View File
@@ -28,7 +28,9 @@ export enum OpenAPITags {
HealthCheck = "Health Check", HealthCheck = "Health Check",
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
PublicResourceLegacy = "Public Resource (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)",
PrivateResourceLegacy = "Private Resource (Legacy)" PrivateResourceLegacy = "Private Resource (Legacy)",
AiProvider = "AI Provider",
AiModel = "AI Model"
} }
// Order here controls the order tags are displayed in Swagger UI // Order here controls the order tags are displayed in Swagger UI
+10 -1
View File
@@ -25,7 +25,6 @@ import {
Transaction Transaction
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
import axios from "axios"; import axios from "axios";
import config from "../config"; import config from "../config";
@@ -330,6 +329,16 @@ export async function listExitNodes(
return exitNodesList; return exitNodesList;
} }
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
/** /**
* Selects the most suitable exit node from a list of ping results. * Selects the most suitable exit node from a list of ping results.
* *
@@ -18,6 +18,7 @@ import {
domains, domains,
exitNodes, exitNodes,
loginPage, loginPage,
SiteResource,
targetHealthCheck targetHealthCheck
} from "@server/db"; } from "@server/db";
import { import {
@@ -134,7 +135,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 +360,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" &&
@@ -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) =>
@@ -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,
+151
View File
@@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import {
aiBudgetUnitSchema,
refineBudgetFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
modelKey: z.string().nonempty(),
name: z.string().nonempty(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "put",
path: "/ai-provider/{providerId}/model",
description: "Create an AI model under a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const { modelKey, name, budgetAmount, budgetUnit, enabled } =
parsedBody.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, providerId),
eq(aiModels.modelKey, modelKey)
)
)
.limit(1);
if (existing) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${modelKey} already exists for this provider`
)
);
}
const now = Date.now();
const [model] = await db
.insert(aiModels)
.values({
providerId,
modelKey,
name,
budgetAmount: budgetAmount ?? null,
budgetUnit: budgetUnit ?? null,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,150 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty(),
type: aiProviderTypeSchema,
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineProviderUpstreamFields(data, ctx);
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/ai-provider",
description: "Create an AI provider for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
name,
type,
upstreamUrl,
apiKey,
authType,
routingMode,
skipTlsVerification,
budgetAmount,
budgetUnit,
enabled
} = parsedBody.data;
const key = config.getRawConfig().server.secret!;
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
const now = Date.now();
const resolvedRoutingMode =
type === "custom" ? (routingMode ?? "url") : "url";
const [provider] = await db
.insert(aiProviders)
.values({
orgId,
name,
type,
upstreamUrl:
resolvedRoutingMode === "target"
? null
: (upstreamUrl ?? null),
apiKey: encryptedApiKey,
apiKeyLastChars,
authType: authType ?? null,
routingMode: resolvedRoutingMode,
skipTlsVerification: skipTlsVerification ?? false,
budgetAmount: budgetAmount ?? null,
budgetUnit: budgetUnit ?? null,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,79 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-model/{modelId}",
description: "Delete an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI model deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-provider/{providerId}",
description: "Delete an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [existing] = await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
await db
.delete(aiProviders)
.where(eq(aiProviders.providerId, providerId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI provider deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-model/{modelId}",
description: "Get an AI model by ID.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [model] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
return response<GetAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,82 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
return response<GetAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./createAiModel";
export * from "./listAiModels";
export * from "./getAiModel";
export * from "./updateAiModel";
export * from "./deleteAiModel";
export * from "./types";
+160
View File
@@ -0,0 +1,160 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/models",
description: "List AI models for a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiModels.providerId, providerId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiModels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiModels)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiModels)
.where(and(...conditions))
.as("filtered_ai_models")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiModels.name))
]);
return response<ListAiModelsResponse>(res, {
data: {
models: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,152 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-providers",
description: "List AI providers for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiProviders.orgId, orgId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiProviders.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiProviders)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiProviders)
.where(and(...conditions))
.as("filtered_ai_providers")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiProviders.name))
]);
return response<ListAiProvidersResponse>(res, {
data: {
providers: rows.map(toPublicAiProvider),
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI providers retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { AiModel, AiProvider } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
resolveAiProviderConfig,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType | null;
};
export type ListAiProvidersResponse = PaginatedResponse<{
providers: AiProviderPublic[];
}>;
export type GetAiProviderResponse = {
provider: AiProviderPublic;
};
export type CreateOrEditAiProviderResponse = {
provider: AiProviderPublic;
};
export type ListAiModelsResponse = PaginatedResponse<{
models: AiModel[];
}>;
export type GetAiModelResponse = {
model: AiModel;
};
export type CreateOrEditAiModelResponse = {
model: AiModel;
};
export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
const { apiKey: _apiKey, ...rest } = provider;
const resolved = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
return {
...rest,
effectiveUpstreamUrl: resolved.upstreamUrl,
effectiveAuthType: resolved.authType
};
}
+167
View File
@@ -0,0 +1,167 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import {
aiBudgetUnitSchema,
refineBudgetFields
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
modelKey: z.string().nonempty().optional(),
name: z.string().nonempty().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "post",
path: "/ai-model/{modelId}",
description: "Update an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (
body.modelKey !== undefined &&
body.modelKey !== existing.modelKey
) {
const [conflict] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, existing.providerId),
eq(aiModels.modelKey, body.modelKey),
ne(aiModels.modelId, modelId)
)
)
.limit(1);
if (conflict) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${body.modelKey} already exists for this provider`
)
);
}
}
const updateData: Partial<typeof aiModels.$inferInsert> = {
updatedAt: Date.now()
};
if (body.modelKey !== undefined) {
updateData.modelKey = body.modelKey;
}
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.budgetAmount !== undefined) {
updateData.budgetAmount = body.budgetAmount;
}
if (body.budgetUnit !== undefined) {
updateData.budgetUnit = body.budgetUnit;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
const [model] = await db
.update(aiModels)
.set(updateData)
.where(eq(aiModels.modelId, modelId))
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,208 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import type {
AiProviderRoutingMode,
AiProviderType
} from "@server/lib/aiProviderDefaults";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty().optional(),
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineBudgetFields(data, ctx);
});
registry.registerPath({
method: "post",
path: "/ai-provider/{providerId}",
description: "Update an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const providerType = existing.type as AiProviderType;
const nextRoutingMode: AiProviderRoutingMode =
providerType === "custom"
? ((body.routingMode ??
existing.routingMode) as AiProviderRoutingMode)
: "url";
const nextUpstreamUrl =
body.upstreamUrl !== undefined
? body.upstreamUrl
: existing.upstreamUrl;
const nextAuthType =
body.authType !== undefined ? body.authType : existing.authType;
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema.nullable().optional(),
routingMode: aiRoutingModeSchema.optional()
})
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
.safeParse({
type: providerType,
upstreamUrl: nextUpstreamUrl,
authType: nextAuthType,
routingMode: nextRoutingMode
});
if (!validation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(validation.error).toString()
)
);
}
const updateData: Partial<typeof aiProviders.$inferInsert> = {
updatedAt: Date.now(),
routingMode: nextRoutingMode
};
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.skipTlsVerification !== undefined) {
updateData.skipTlsVerification = body.skipTlsVerification;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
if (body.budgetAmount !== undefined) {
updateData.budgetAmount = body.budgetAmount;
}
if (body.budgetUnit !== undefined) {
updateData.budgetUnit = body.budgetUnit;
}
if (nextRoutingMode === "target") {
updateData.upstreamUrl = null;
} else if (body.upstreamUrl !== undefined) {
updateData.upstreamUrl = body.upstreamUrl;
}
if (body.authType !== undefined) {
updateData.authType = body.authType;
}
if (body.apiKey !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.apiKey = encrypt(body.apiKey, key);
updateData.apiKeyLastChars = body.apiKey.slice(-4);
}
const [provider] = await db
.update(aiProviders)
.set(updateData)
.where(eq(aiProviders.providerId, providerId))
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
success: true,
error: false,
message: "AI provider updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from "zod";
import {
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
export const aiProviderTypeSchema = z.enum([
"openai",
"anthropic",
"googleGemini",
"vertexAi",
"bedrock",
"microsoftFoundry",
"openRouter",
"vercelAiGateway",
"custom"
]);
export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
export const aiAuthTypeSchema = z.enum(["bearer"]);
export const aiRoutingModeSchema = z.enum(["url", "target"]);
export function refineBudgetFields(
data: {
budgetAmount?: number | null;
budgetUnit?: AiBudgetUnit | null;
},
ctx: z.RefinementCtx
) {
const hasAmount =
data.budgetAmount !== undefined && data.budgetAmount !== null;
const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null;
if (hasAmount !== hasUnit) {
ctx.addIssue({
code: "custom",
message:
"budgetAmount and budgetUnit must both be set or both omitted",
path: hasAmount ? ["budgetUnit"] : ["budgetAmount"]
});
}
}
export function refineProviderUpstreamFields(
data: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: "bearer" | null;
routingMode?: AiProviderRoutingMode | null;
},
ctx: z.RefinementCtx
) {
const routingMode = data.routingMode ?? "url";
if (data.type !== "custom" && routingMode === "target") {
ctx.addIssue({
code: "custom",
message: "routingMode target is only allowed for custom providers",
path: ["routingMode"]
});
}
if (
providerRequiresUpstreamUrl(data.type, routingMode) &&
!data.upstreamUrl
) {
ctx.addIssue({
code: "custom",
message: `upstreamUrl is required for ${data.type} providers`,
path: ["upstreamUrl"]
});
}
if (data.type === "custom" && routingMode === "url" && !data.authType) {
ctx.addIssue({
code: "custom",
message: "authType is required for custom providers",
path: ["authType"]
});
}
}
-15
View File
@@ -255,20 +255,6 @@ export async function createClient(
let newClient: Client | null = null; let newClient: Client | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
)
);
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -287,7 +273,6 @@ export async function createClient(
.insert(clients) .insert(clients)
.values({ .values({
niceId, niceId,
exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
name, name,
subnet: updatedSubnet, subnet: updatedSubnet,
@@ -222,11 +222,6 @@ export async function createUserClient(
let newClient: Client | null = null; let newClient: Client | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -244,7 +239,6 @@ export async function createUserClient(
[newClient] = await trx [newClient] = await trx
.insert(clients) .insert(clients)
.values({ .values({
exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
niceId, niceId,
name, name,
+17 -6
View File
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["megabytesIn", "megabytesOut"]) .enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["megabytesIn", "megabytesOut"], enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
description: "Field to sort by" description: "Field to sort by"
}), }),
order: z order: z
@@ -183,7 +183,9 @@ function queryUserDevicesBase() {
fingerprintArch: currentFingerprint.arch, fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username, fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname fingerprintHostname: currentFingerprint.hostname,
firstSeen: currentFingerprint.firstSeen,
lastSeen: currentFingerprint.lastSeen
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))
@@ -389,14 +391,23 @@ export async function listUserDevices(
const countQuery = db.$count(baseQuery.as("filtered_clients")); const countQuery = db.$count(baseQuery.as("filtered_clients"));
const sortColumn =
sort_by === "firstSeen"
? currentFingerprint.firstSeen
: sort_by === "lastSeen"
? currentFingerprint.lastSeen
: sort_by
? clients[sort_by]
: undefined;
const listDevicesQuery = baseQuery const listDevicesQuery = baseQuery
.limit(pageSize) .limit(pageSize)
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy( .orderBy(
sort_by sortColumn
? order === "asc" ? order === "asc"
? asc(clients[sort_by]) ? asc(sortColumn)
: desc(clients[sort_by]) : desc(sortColumn)
: asc(clients.clientId) : asc(clients.clientId)
); );
+97 -1
View File
@@ -45,7 +45,9 @@ import {
verifySiteResourceAccess, verifySiteResourceAccess,
verifyOlmAccess, verifyOlmAccess,
verifyLimits, verifyLimits,
verifyResourcePolicyAccess verifyResourcePolicyAccess,
verifyAiProviderAccess,
verifyAiModelAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -55,6 +57,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares"; import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws"; import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels"; import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@@ -1366,6 +1369,99 @@ authenticated.get(
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
authenticated.put(
"/org/:orgId/ai-provider",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createAiProvider),
logActionAudit(ActionsEnum.createAiProvider),
aiProvider.createAiProvider
);
authenticated.get(
"/org/:orgId/ai-providers",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAiProviders),
aiProvider.listAiProviders
);
authenticated.get(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.put(
"/ai-provider/:providerId/target",
verifyAiProviderAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
target.createTarget
);
authenticated.get(
"/ai-provider/:providerId/targets",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listTargets),
target.listTargets
);
authenticated.post(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.updateAiProvider),
logActionAudit(ActionsEnum.updateAiProvider),
aiProvider.updateAiProvider
);
authenticated.delete(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.deleteAiProvider),
logActionAudit(ActionsEnum.deleteAiProvider),
aiProvider.deleteAiProvider
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.createAiModel),
logActionAudit(ActionsEnum.createAiModel),
aiProvider.createAiModel
);
authenticated.get(
"/ai-provider/:providerId/models",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listAiModels),
aiProvider.listAiModels
);
authenticated.get(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.getAiModel),
aiProvider.getAiModel
);
authenticated.post(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.updateAiModel),
logActionAudit(ActionsEnum.updateAiModel),
aiProvider.updateAiModel
);
authenticated.delete(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.deleteAiModel),
logActionAudit(ActionsEnum.deleteAiModel),
aiProvider.deleteAiModel
);
authenticated.get( authenticated.get(
"/org/:orgId/labels", "/org/:orgId/labels",
verifyOrgAccess, verifyOrgAccess,
+4 -4
View File
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Filter to sites with the required fields up front so the rest of the // Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined. // function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter( const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort (s) => s.endpoint && s.exitNodeSubnet && s.listenPort
); );
if (validSites.length === 0) { if (validSites.length === 0) {
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
if ( if (
peer.orgId == null || peer.orgId == null ||
!peer.endpoint || !peer.endpoint ||
!peer.subnet || !peer.exitNodeSubnet ||
!peer.listenPort !peer.listenPort
) { ) {
continue; continue;
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Process each site using the pre-fetched data. // Process each site using the pre-fetched data.
for (const site of validSites) { for (const site of validSites) {
const siteDestination: PeerDestination = { const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0], destinationIP: site.exitNodeSubnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}; };
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
continue; continue;
} }
addDestination(site.endpoint!, { addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0], destinationIP: peer.exitNodeSubnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
}); });
} }
+2 -2
View File
@@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
and( and(
eq(sites.exitNodeId, exitNode.exitNodeId), eq(sites.exitNodeId, exitNode.exitNodeId),
isNotNull(sites.pubKey), isNotNull(sites.pubKey),
isNotNull(sites.subnet) isNotNull(sites.exitNodeSubnet)
) )
); );
@@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
} else if (site.type === "newt") { } else if (site.type === "newt") {
return { return {
publicKey: site.pubKey, publicKey: site.pubKey,
allowedIps: [site.subnet!] allowedIps: [site.exitNodeSubnet!]
}; };
} }
return { return {
+1 -1
View File
@@ -186,7 +186,7 @@ export async function updateAndGenerateEndpointDestinations(
.select({ .select({
siteId: sites.siteId, siteId: sites.siteId,
newtId: newts.newtId, newtId: newts.newtId,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
listenPort: sites.listenPort, listenPort: sites.listenPort,
publicKey: sites.publicKey, publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint, endpoint: clientSitesAssociationsCache.endpoint,
+95
View File
@@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys";
import * as idp from "./idp"; import * as idp from "./idp";
import * as logs from "./auditLogs"; import * as logs from "./auditLogs";
import * as siteResource from "./siteResource"; import * as siteResource from "./siteResource";
import * as aiProvider from "./aiProvider";
import { import {
verifyApiKey, verifyApiKey,
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -31,6 +32,8 @@ import {
verifyLimits, verifyLimits,
verifyApiKeyDomainAccess, verifyApiKeyDomainAccess,
verifyApiKeyResourcePolicyAccess, verifyApiKeyResourcePolicyAccess,
verifyApiKeyAiProviderAccess,
verifyApiKeyAiModelAccess,
verifyUserHasAction verifyUserHasAction
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -1366,3 +1369,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
);
@@ -1,7 +1,8 @@
import { SiteResource } from "@server/db";
import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
export type SiteResourceDestinationInput = { export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh"; mode: SiteResource["mode"];
destination: string | null; destination: string | null;
destinationPort: number | null; destinationPort: number | null;
scheme: "http" | "https" | null; scheme: "http" | "https" | null;
+10 -6
View File
@@ -15,7 +15,7 @@ import {
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import { import {
@@ -211,7 +211,8 @@ export async function buildClientConfigurationForNewtClient(
// call rather than letting each resource fetch its own — with thousands // call rather than letting each resource fetch its own — with thousands
// of resources this avoids a concurrent DB/cache stampede for what is // of resources this avoids a concurrent DB/cache stampede for what is
// often the very same (e.g. wildcard) certificate. // often the very same (e.g. wildcard) certificate.
const certByDomain = await batchFetchCertsForSiteResources(allSiteResources); const certByDomain =
await batchFetchCertsForSiteResources(allSiteResources);
const resourceTargetsArr = await Promise.all( const resourceTargetsArr = await Promise.all(
allSiteResources.map((resource) => allSiteResources.map((resource) =>
@@ -240,7 +241,7 @@ export async function buildTargetConfigurationForNewtClient(
version?: string | null, version?: string | null,
remoteExitNodeId?: string remoteExitNodeId?: string
) { ) {
// Get all enabled targets with their resource mode information // Get enabled HTTP/TCP/UDP targets for resources and AI providers
const allTargets = await db const allTargets = await db
.select({ .select({
resourceId: targets.resourceId, resourceId: targets.resourceId,
@@ -250,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled, enabled: targets.enabled,
mode: resources.mode mode: sql<string>`COALESCE(${resources.mode}, ${targets.mode})`.mapWith(
String
)
}) })
.from(targets) .from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .leftJoin(resources, eq(targets.resourceId, resources.resourceId))
.where( .where(
and( and(
eq(targets.siteId, siteId), eq(targets.siteId, siteId),
eq(targets.enabled, true), eq(targets.enabled, true),
inArray(targets.mode, ["http", "udp", "tcp"]) inArray(targets.mode, ["http", "udp", "tcp"]),
or(isNotNull(targets.resourceId), isNotNull(targets.providerId))
) )
); );
@@ -2,14 +2,17 @@ import { db, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt } from "@server/db"; import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ne, eq, or, and, count } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { listExitNodes } from "#dynamic/lib/exitNodes"; import { listExitNodes } from "#dynamic/lib/exitNodes";
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => { export const handleNewtExitNodesRequestMessage: MessageHandler = async (
context
) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
logger.info("Handling ping request newt message!"); logger.info("Handling exit nodes request newt message!");
if (!newt) { if (!newt) {
logger.warn("Newt not found"); logger.warn("Newt not found");
@@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesPayload = await Promise.all( const exitNodesPayload = await Promise.all(
exitNodesList.map(async (node) => { exitNodesList.map(async (node) => {
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) const weight = await calculateExitNodeWeight(
// higher = more desirable node.exitNodeId,
// like saying, this node has x% of its capacity left node.maxConnections
);
let weight = 1; if (weight === null) {
const maxConnections = node.maxConnections; return null;
if (maxConnections !== null && maxConnections !== undefined) {
const [currentConnections] = await db
.select({
count: count()
})
.from(sites)
.where(
and(
eq(sites.exitNodeId, node.exitNodeId),
eq(sites.online, true)
)
);
if (currentConnections.count >= maxConnections) {
return null;
}
weight =
(maxConnections - currentConnections.count) /
maxConnections;
} }
return { return {
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
if ( if (
exitNode.reachableAt && exitNode.reachableAt &&
existingSite.subnet && existingSite.exitNodeSubnet &&
existingSite.listenPort existingSite.listenPort
) { ) {
const payload = { const payload = {
oldDestination: { oldDestination: {
destinationIP: existingSite.subnet?.split("/")[0], destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}, },
newDestination: { newDestination: {
destinationIP: site.subnet?.split("/")[0], destinationIP: site.exitNodeSubnet?.split("/")[0],
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
} }
}; };
@@ -132,7 +132,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets)); ({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
} }
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format const targetsToSend = await convertTargetsIfNecessary(
newt.newtId,
dedupedTargets
); // for backward compatibility with old newt versions that don't support the new target format
return { return {
message: { message: {
@@ -1,30 +1,20 @@
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { db, newts, remoteExitNodes } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db"; import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers"; import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { import {
ExitNodePingResult,
selectBestExitNode, selectBestExitNode,
verifyExitNodeOrgAccess verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes"; } from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { lockManager } from "#dynamic/lib/lock";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
const newt = client as Newt; const newt = client as Newt;
@@ -94,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId); fetchContainers(newt.newtId);
} }
let siteSubnet = oldSite.subnet; let siteSubnet = oldSite.exitNodeSubnet;
let exitNodeIdToQuery = oldSite.exitNodeId; let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { if (
exitNodeId &&
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
) {
// This effectively moves the exit node to the new one // This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
const newSubnet = await getUniqueSubnetForSite(exitNode); const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) { if (!newSubnet) {
logger.error( logger.error(
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
exitNodeId: exitNodeId, exitNodeId: exitNodeId,
subnet: newSubnet exitNodeSubnet: newSubnet
}) })
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.returning(); .returning();
@@ -250,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
excludeSender: false // Include sender in broadcast excludeSender: false // Include sender in broadcast
}; };
}; };
async function getUniqueSubnetForSite(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const sitesQuery = await trx
.select({
subnet: sites.subnet
})
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery
.map((site) => site.subnet)
.filter(
(subnet) =>
subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
)
.filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,
blockSize,
exitNode.address
);
return newSubnet;
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage"; export * from "./handleReceiveBandwidthMessage";
export * from "./handleNewtGetConfigMessage"; export * from "./handleNewtGetConfigMessage";
export * from "./handleSocketMessages"; export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage"; export * from "./handleNewtExitNodesRequestMessage";
export * from "./handleApplyBlueprintMessage"; export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage"; export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage"; export * from "./handleNewtDisconnectingMessage";
+29 -8
View File
@@ -19,6 +19,7 @@ import logger from "@server/logger";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { SiR } from "react-icons/si";
export async function buildSiteConfigurationForOlmClient( export async function buildSiteConfigurationForOlmClient(
client: Client, client: Client,
@@ -38,6 +39,8 @@ export async function buildSiteConfigurationForOlmClient(
aliases: Alias[]; aliases: Alias[];
}[] = []; }[] = [];
let exitNodeAliases: string[] = [];
// Get all sites data // Get all sites data
const sitesData = await db const sitesData = await db
.select() .select()
@@ -48,10 +51,6 @@ export async function buildSiteConfigurationForOlmClient(
) )
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
if (sitesData.length === 0) {
return siteConfigurations;
}
// Batch-fetch every site resource this client has access to across ALL sites // Batch-fetch every site resource this client has access to across ALL sites
// in a single query, then group by siteId in memory. This avoids issuing one // in a single query, then group by siteId in memory. This avoids issuing one
// query per site (which would be N round-trips for N sites). // query per site (which would be N round-trips for N sites).
@@ -68,8 +67,8 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .leftJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(
and( and(
eq( eq(
@@ -81,7 +80,15 @@ export async function buildSiteConfigurationForOlmClient(
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
let siteResourcesForExitNode = [];
for (const row of allClientSiteResources) { for (const row of allClientSiteResources) {
if (row.siteResource.requiresExitNodeConnection) {
siteResourcesForExitNode.push(row.siteResource);
}
if (!row.siteId) {
// because we are doing a leftJoin above to get the inference resources without a network / sites
continue;
}
const arr = siteResourcesBySiteId.get(row.siteId); const arr = siteResourcesBySiteId.get(row.siteId);
if (arr) { if (arr) {
arr.push(row.siteResource); arr.push(row.siteResource);
@@ -90,6 +97,17 @@ export async function buildSiteConfigurationForOlmClient(
} }
} }
exitNodeAliases = siteResourcesForExitNode
.map((sr) => sr.alias)
.filter((a) => a != null);
if (sitesData.length == 0) {
return {
siteConfigurations,
exitNodeAliases
};
}
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode). // Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>(); const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
if (!jitMode && relay) { if (!jitMode && relay) {
@@ -167,7 +185,7 @@ export async function buildSiteConfigurationForOlmClient(
peerOps.push(deletePeer(site.siteId, client.pubKey!)); peerOps.push(deletePeer(site.siteId, client.pubKey!));
} }
if (!site.subnet) { if (!site.exitNodeSubnet) {
logger.debug(`Site ${site.siteId} has no subnet, skipping`); logger.debug(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -226,5 +244,8 @@ export async function buildSiteConfigurationForOlmClient(
}); });
} }
return siteConfigurations; return {
siteConfigurations,
exitNodeAliases
};
} }
@@ -0,0 +1,93 @@
import { db, clients } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Olm } from "@server/db";
import logger from "@server/logger";
import { eq } from "drizzle-orm";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
export const handleOlmExitNodesRequestMessage: MessageHandler = async (
context
) => {
const { message, client: olmClient, sendToClient } = context;
const olm = olmClient as Olm;
logger.info("Handling exit nodes request olm message!");
if (!olm) {
logger.warn("olm not found");
return;
}
// Get the olm's orgId through the client relationship
if (!olm.clientId) {
logger.warn("olm clientId not found");
return;
}
const [client] = await db
.select({ orgId: clients.orgId })
.from(clients)
.where(eq(clients.clientId, olm.clientId))
.limit(1);
if (!client || !client.orgId) {
logger.warn("client not found");
return;
}
const { noCloud, chainId } = message.data;
const exitNodesList = await listExitNodes(
client.orgId,
true,
noCloud || false,
olm.clientId
); // filter for only the online ones
let lastExitNodeId = null;
if (olm.clientId) {
const [lastExitNode] = await db
.select()
.from(clients)
.where(eq(clients.clientId, olm.clientId))
.limit(1);
lastExitNodeId = lastExitNode?.exitNodeId || null;
}
const exitNodesPayload = await Promise.all(
exitNodesList.map(async (node) => {
const weight = await calculateExitNodeWeight(
node.exitNodeId,
node.maxConnections
);
if (weight === null) {
return null;
}
return {
exitNodeId: node.exitNodeId,
exitNodeName: node.name,
endpoint: node.endpoint,
weight,
wasPreviouslyConnected: node.exitNodeId === lastExitNodeId
};
})
);
// filter out null values
const filteredExitNodes = exitNodesPayload.filter((node) => node !== null);
return {
message: {
type: "olm/ping/exitNodes",
data: {
exitNodes: filteredExitNodes,
chainId: chainId
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
};
};
+107 -8
View File
@@ -1,4 +1,4 @@
import { db, orgs, primaryDb } from "@server/db"; import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
@@ -22,6 +22,12 @@ import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
import {
ExitNodePingResult,
selectBestExitNode,
verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -49,11 +55,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
olmAgent, olmAgent,
orgId, orgId,
userToken, userToken,
pingResults,
fingerprint, fingerprint,
postures, postures,
backwardsCompatible,
chainId chainId
} = message.data; } = message.data;
if (backwardsCompatible) {
logger.debug(
"[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response."
);
return;
}
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("[handleOlmRegisterMessage] Olm client ID not found"); logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
@@ -284,7 +299,64 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
if (client.pubKey !== publicKey || client.archived) { let exitNodeId: number | undefined;
if (pingResults) {
const bestPingResult = selectBestExitNode(
pingResults as ExitNodePingResult[]
);
if (!bestPingResult) {
logger.warn("No suitable exit node found based on ping results");
}
exitNodeId = bestPingResult?.exitNodeId;
}
let clientSubnet = client.exitNodeSubnet;
if (
exitNodeId &&
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
) {
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
exitNodeId,
client.orgId
);
if (!exitNode) {
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
orgId: client.orgId,
clientId: client.clientId
});
return;
}
if (!hasAccess) {
logger.warn(
"[handleOlmRegisterMessage] Not authorized to use this exit node",
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
// TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT?
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) {
logger.error(
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}
clientSubnet = newSubnet;
}
if (
client.pubKey !== publicKey ||
client.archived ||
client.exitNodeId !== exitNodeId ||
client.exitNodeSubnet !== clientSubnet
) {
logger.info( logger.info(
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...", "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
{ orgId: client.orgId, clientId: client.clientId } { orgId: client.orgId, clientId: client.clientId }
@@ -294,7 +366,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.update(clients) .update(clients)
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
archived: false archived: false,
exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
exitNodeSubnet: clientSubnet
}) })
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
@@ -376,14 +450,28 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
let exitNode: ExitNode | null = null;
if (exitNodeId) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
}
// NOTE: its important that the client here is the old client and the public key is the new key // NOTE: its important that the client here is the old client and the public key is the new key
await waitForClientRebuildIdle(olm.clientId); await waitForClientRebuildIdle(olm.clientId);
const siteConfigurations = await buildSiteConfigurationForOlmClient( const { siteConfigurations, exitNodeAliases } =
client, await buildSiteConfigurationForOlmClient(
publicKey, client,
relay, publicKey,
jitMode relay,
jitMode
);
logger.info(
`+++++++++++++++++++++++++++++++ ExitNode Aliases: ${exitNodeAliases}`
); );
// Return connect message with all site configurations // Return connect message with all site configurations
@@ -394,6 +482,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sites: siteConfigurations, sites: siteConfigurations,
tunnelIP: client.subnet, tunnelIP: client.subnet,
utilitySubnet: org.utilitySubnet, utilitySubnet: org.utilitySubnet,
exitNode:
exitNode && client.exitNodeSubnet
? {
aliases: exitNodeAliases,
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0]
}
: undefined,
chainId: chainId chainId: chainId
} }
}, },
+1
View File
@@ -15,3 +15,4 @@ export * from "./handleOlmServerInitAddPeerHandshake";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./handleOlmUnLocalMessage"; export * from "./handleOlmUnLocalMessage";
export * from "./handleOlmLocalMessage"; export * from "./handleOlmLocalMessage";
export * from "./handleOlmExitNodesRequestMessage";
+2 -2
View File
@@ -44,11 +44,11 @@ const createResourceParamsSchema = z.strictObject({
}); });
function resolveModeFromLegacyFields(data: { function resolveModeFromLegacyFields(data: {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
http?: boolean; http?: boolean;
protocol?: "tcp" | "udp"; protocol?: "tcp" | "udp";
}): { }): {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
error?: string; error?: string;
} { } {
if (data.mode) { if (data.mode) {
+2 -1
View File
@@ -637,11 +637,12 @@ export async function listResources(
${resourcePassword.passwordId} ${resourcePassword.passwordId}
) )
`; `;
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"]; const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
switch (authState) { switch (authState) {
case "none": case "none":
conditions.push( conditions.push(
// TODO: Does inference belong here?
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp")) or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
); );
break; break;
+4 -4
View File
@@ -311,13 +311,13 @@ export async function createSite(
// lets also make sure there is no overlap with other sites on the exit node // lets also make sure there is no overlap with other sites on the exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where( .where(
and( and(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
eq(sites.subnet, subnet) eq(sites.exitNodeSubnet, subnet)
) )
); );
@@ -427,7 +427,7 @@ export async function createSite(
exitNodeId, exitNodeId,
name, name,
niceId: updatedNiceId!, niceId: updatedNiceId!,
subnet, exitNodeSubnet: subnet,
type, type,
pubKey: pubKey || null, pubKey: pubKey || null,
status: "approved" status: "approved"
@@ -444,7 +444,7 @@ export async function createSite(
type, type,
dockerSocketEnabled: false, dockerSocketEnabled: false,
online: true, online: true,
subnet: "0.0.0.0/32", exitNodeSubnet: "0.0.0.0/32",
status: "approved" status: "approved"
}) })
.returning(); .returning();
+1 -1
View File
@@ -125,7 +125,7 @@ function querySitesBase() {
niceId: sites.niceId, niceId: sites.niceId,
name: sites.name, name: sites.name,
pubKey: sites.pubKey, pubKey: sites.pubKey,
subnet: sites.subnet, subnet: sites.exitNodeSubnet,
megabytesIn: sites.megabytesIn, megabytesIn: sites.megabytesIn,
megabytesOut: sites.megabytesOut, megabytesOut: sites.megabytesOut,
orgName: orgs.name, orgName: orgs.name,
+4 -3
View File
@@ -43,7 +43,6 @@ const PickSiteDefaultsResponseDataSchema = z.object({
clientAddress: z.string().optional() clientAddress: z.string().optional()
}); });
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/pick-site-defaults", path: "/org/{orgId}/pick-site-defaults",
@@ -60,7 +59,9 @@ registry.registerPath({
description: "Successful response", description: "Successful response",
content: { content: {
"application/json": { "application/json": {
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema) schema: createApiResponseSchema(
PickSiteDefaultsResponseDataSchema
)
} }
} }
} }
@@ -108,7 +109,7 @@ export async function pickSiteDefaults(
// list all of the sites on that exit node // list all of the sites on that exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet subnet: sites.exitNodeSubnet
}) })
.from(sites) .from(sites)
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); .where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
@@ -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(),
@@ -171,6 +171,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
@@ -487,7 +490,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 +531,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 +541,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 +577,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 +593,8 @@ 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
}; };
if (isLicensedSshPam) { if (isLicensedSshPam) {
if (authDaemonPort !== undefined) if (authDaemonPort !== undefined)
@@ -600,11 +612,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
@@ -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(),
@@ -173,6 +173,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;
@@ -504,7 +507,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 +586,14 @@ 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,
...sshPamSet ...sshPamSet
}) })
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(and(eq(siteResources.siteResourceId, siteResourceId)))
@@ -593,7 +601,20 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
if (siteIds !== undefined) { if (mode === "inference") {
// inference resources are not attached to any site network
if (existingSiteResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
existingSiteResource.networkId
)
);
}
updatedSiteIds = [];
} else if (siteIds !== undefined) {
// delete the site - site resources associations // delete the site - site resources associations
await trx await trx
.delete(siteNetworks) .delete(siteNetworks)
+164 -32
View File
@@ -6,7 +6,14 @@ import {
TargetHealthCheck, TargetHealthCheck,
targetHealthCheck targetHealthCheck
} from "@server/db"; } from "@server/db";
import { newts, resources, sites, Target, targets } from "@server/db"; import {
aiProviders,
newts,
resources,
sites,
Target,
targets
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -29,10 +36,19 @@ import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const createTargetParamsSchema = z.strictObject({ const resourceTargetParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
}); });
const providerTargetParamsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const createTargetParamsSchema = z.union([
resourceTargetParamsSchema,
providerTargetParamsSchema
]);
const createTargetSchema = z const createTargetSchema = z
.strictObject({ .strictObject({
siteId: z.int().positive(), siteId: z.int().positive(),
@@ -95,7 +111,7 @@ registry.registerPath({
description: "Create a target for a resource.", description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResourceLegacy], tags: [OpenAPITags.PublicResourceLegacy],
request: { request: {
params: createTargetParamsSchema, params: resourceTargetParamsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
@@ -128,7 +144,40 @@ registry.registerPath({
description: "Create a target for a resource.", description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target], tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
request: { request: {
params: createTargetParamsSchema, params: resourceTargetParamsSchema,
body: {
content: {
"application/json": {
schema: createTargetSchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "put",
path: "/ai-provider/{providerId}/target",
description: "Create a target for an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: providerTargetParamsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
@@ -183,21 +232,74 @@ export async function createTarget(
); );
} }
const { resourceId } = parsedParams.data; let resource: typeof resources.$inferSelect | undefined;
let provider: typeof aiProviders.$inferSelect | undefined;
// get the resource if ("providerId" in parsedParams.data) {
const [resource] = await db const { providerId } = parsedParams.data;
.select() [provider] =
.from(resources) req.aiProvider && req.aiProvider.providerId === providerId
.where(eq(resources.resourceId, resourceId)); ? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!resource) { if (!provider) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found` `AI provider with ID ${providerId} not found`
) )
); );
}
if (provider.routingMode !== "target") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider must use target routing mode"
)
);
}
if (provider.type !== "custom") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Only custom AI providers support targets"
)
);
}
if (
targetData.method &&
!["http", "https"].includes(targetData.method.toLowerCase())
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider target method must be http or https"
)
);
}
} else {
const { resourceId } = parsedParams.data;
[resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
} }
const siteId = targetData.siteId; const siteId = targetData.siteId;
@@ -217,6 +319,24 @@ export async function createTarget(
); );
} }
if (provider && site.orgId && site.orgId !== provider.orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site must belong to the AI provider organization"
)
);
}
const resourceId = resource?.resourceId ?? null;
const providerId = provider?.providerId ?? null;
const targetMode = provider
? "http"
: (targetData.mode ?? resource?.mode ?? "http");
const targetMethod = provider
? (targetData.method?.toLowerCase() ?? "https")
: targetData.method;
const plainToken = generateId(48); const plainToken = generateId(48);
const encryptedToken = encrypt( const encryptedToken = encrypt(
plainToken, plainToken,
@@ -230,20 +350,24 @@ export async function createTarget(
const existingTargets = await trx const existingTargets = await trx
.select() .select()
.from(targets) .from(targets)
.where(eq(targets.resourceId, resourceId)); .where(
providerId
? eq(targets.providerId, providerId)
: eq(targets.resourceId, resourceId!)
);
const existingTarget = existingTargets.find( const existingTarget = existingTargets.find(
(target) => (target) =>
target.ip === targetData.ip && target.ip === targetData.ip &&
target.port === targetData.port && target.port === targetData.port &&
target.method === targetData.method && target.method === targetMethod &&
target.siteId === targetData.siteId target.siteId === targetData.siteId
); );
if (existingTarget) { if (existingTarget) {
// log a warning // log a warning
logger.warn( logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetMethod} already exists for ${providerId ? `AI provider ID ${providerId}` : `resource ID ${resourceId}`}`
); );
} }
@@ -252,10 +376,10 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
providerId,
...targetData, ...targetData,
mode: (targetData.mode ?? mode: targetMode as Target["mode"],
resource.mode ?? method: targetMethod,
"http") as Target["mode"],
priority: targetData.priority || 100 priority: targetData.priority || 100
}) })
.returning(); .returning();
@@ -263,7 +387,7 @@ export async function createTarget(
// make sure the target is within the site subnet // make sure the target is within the site subnet
if ( if (
site.type == "wireguard" && site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!) !isIpInCidr(targetData.ip, site.exitNodeSubnet!)
) { ) {
return next( return next(
createHttpError( createHttpError(
@@ -289,13 +413,12 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
providerId,
siteId: site.siteId, siteId: site.siteId,
ip: targetData.ip, ip: targetData.ip,
mode: (targetData.mode ?? mode: targetMode as Target["mode"],
resource.mode ??
"http") as Target["mode"],
authToken: encryptedToken, authToken: encryptedToken,
method: targetData.method, method: targetMethod,
port: targetData.port, port: targetData.port,
internalPort, internalPort,
enabled: targetData.enabled, enabled: targetData.enabled,
@@ -321,10 +444,12 @@ export async function createTarget(
healthCheck = await trx healthCheck = await trx
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
orgId: resource.orgId, orgId: provider?.orgId ?? resource!.orgId,
targetId: newTarget[0].targetId, targetId: newTarget[0].targetId,
siteId: targetData.siteId, siteId: targetData.siteId,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, name: provider
? `AI Provider ${provider.name} - ${targetData.ip}:${targetData.port}`
: `Resource ${resource!.name} - ${targetData.ip}:${targetData.port}`,
hcEnabled: targetData.hcEnabled ?? false, hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null, hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null, hcScheme: targetData.hcScheme ?? null,
@@ -399,10 +524,17 @@ export async function createTarget(
newt.newtId, newt.newtId,
newTarget, newTarget,
healthCheck, healthCheck,
resource.mode === "udp" ? "udp" : "tcp", provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { } else if (
!provider &&
["ssh", "rdp", "vnc"].includes(newTarget[0].mode)
) {
await sendBrowserGatewayTargets( await sendBrowserGatewayTargets(
newt.newtId, newt.newtId,
newTarget, newTarget,
+53 -27
View File
@@ -79,38 +79,54 @@ export async function deleteTarget(
) )
); );
} }
// get the resource
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, deletedTarget.resourceId!));
if (!resource) { if (
(!deletedTarget.resourceId && !deletedTarget.providerId) ||
(deletedTarget.resourceId && deletedTarget.providerId)
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${deletedTarget.resourceId} not found` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
// check if there are other targets on the resource let resource: typeof resources.$inferSelect | undefined;
const otherTargets = await db if (deletedTarget.resourceId) {
.select() [resource] = await db
.from(targets) .select()
.where( .from(resources)
and( .where(eq(resources.resourceId, deletedTarget.resourceId))
eq(targets.resourceId, resource.resourceId), .limit(1);
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) { if (!resource) {
// set the resource status return next(
await db createHttpError(
.update(resources) HttpCode.NOT_FOUND,
.set({ health: "unknown" }) `Resource with ID ${deletedTarget.resourceId} not found`
.where(eq(resources.resourceId, resource.resourceId)); )
);
}
// check if there are other targets on the resource
const otherTargets = await db
.select()
.from(targets)
.where(
and(
eq(targets.resourceId, resource.resourceId),
ne(targets.targetId, targetId)
)
);
if (otherTargets.length == 0) {
// set the resource status
await db
.update(resources)
.set({ health: "unknown" })
.where(eq(resources.resourceId, resource.resourceId));
}
} }
const [site] = await db const [site] = await db
@@ -137,16 +153,26 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { if (
deletedTarget.providerId ||
["http", "tcp", "udp"].includes(deletedTarget.mode)
) {
await removeTargets( await removeTargets(
newt.newtId, newt.newtId,
// [deletedTarget], // [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck], [deletedHealthCheck],
resource.mode === "udp" ? "udp" : "tcp", deletedTarget.providerId
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { } else if (
!deletedTarget.providerId &&
["ssh", "rdp", "vnc"].includes(deletedTarget.mode)
) {
await removeBrowserGatewayTarget( await removeBrowserGatewayTarget(
newt.newtId, newt.newtId,
deletedTarget.targetId, deletedTarget.targetId,
+53 -8
View File
@@ -10,10 +10,19 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const listTargetsParamsSchema = z.strictObject({ const resourceTargetsParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
}); });
const providerTargetsParamsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listTargetsParamsSchema = z.union([
resourceTargetsParamsSchema,
providerTargetsParamsSchema
]);
const listTargetsSchema = z.strictObject({ const listTargetsSchema = z.strictObject({
limit: z limit: z
.string() .string()
@@ -29,7 +38,7 @@ const listTargetsSchema = z.strictObject({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
function queryTargets(resourceId: number) { function queryTargets(owner: { resourceId: number } | { providerId: number }) {
const baseQuery = db const baseQuery = db
.select({ .select({
targetId: targets.targetId, targetId: targets.targetId,
@@ -39,6 +48,7 @@ function queryTargets(resourceId: number) {
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,
resourceId: targets.resourceId, resourceId: targets.resourceId,
providerId: targets.providerId,
siteId: targets.siteId, siteId: targets.siteId,
siteType: sites.type, siteType: sites.type,
siteName: sites.name, siteName: sites.name,
@@ -71,7 +81,11 @@ function queryTargets(resourceId: number) {
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
) )
.where(eq(targets.resourceId, resourceId)); .where(
"providerId" in owner
? eq(targets.providerId, owner.providerId)
: eq(targets.resourceId, owner.resourceId)
);
return baseQuery; return baseQuery;
} }
@@ -94,7 +108,7 @@ registry.registerPath({
description: "List targets for a resource.", description: "List targets for a resource.",
tags: [OpenAPITags.PublicResourceLegacy], tags: [OpenAPITags.PublicResourceLegacy],
request: { request: {
params: listTargetsParamsSchema, params: resourceTargetsParamsSchema,
query: listTargetsSchema query: listTargetsSchema
}, },
responses: { responses: {
@@ -121,7 +135,34 @@ registry.registerPath({
description: "List targets for a resource.", description: "List targets for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target], tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
request: { request: {
params: listTargetsParamsSchema, params: resourceTargetsParamsSchema,
query: listTargetsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/targets",
description: "List targets for an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: providerTargetsParamsSchema,
query: listTargetsSchema query: listTargetsSchema
}, },
responses: { responses: {
@@ -168,14 +209,18 @@ export async function listTargets(
) )
); );
} }
const { resourceId } = parsedParams.data; const owner = parsedParams.data;
const ownerCondition =
"providerId" in owner
? eq(targets.providerId, owner.providerId)
: eq(targets.resourceId, owner.resourceId);
const baseQuery = queryTargets(resourceId); const baseQuery = queryTargets(owner);
const countQuery = db const countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` }) .select({ count: sql<number>`cast(count(*) as integer)` })
.from(targets) .from(targets)
.where(eq(targets.resourceId, resourceId)); .where(ownerCondition);
const targetsList = await baseQuery.limit(limit).offset(offset); const targetsList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
+90 -16
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db"; import { aiProviders, newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -147,21 +147,68 @@ export async function updateTarget(
); );
} }
// get the resource if (
const [resource] = await db (!target.resourceId && !target.providerId) ||
.select() (target.resourceId && target.providerId)
.from(resources) ) {
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${target.resourceId} not found` `Target with ID ${targetId} has invalid ownership`
) )
); );
} }
let resource: typeof resources.$inferSelect | undefined;
let provider: typeof aiProviders.$inferSelect | undefined;
if (target.resourceId) {
[resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${target.resourceId} not found`
)
);
}
} else {
[provider] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, target.providerId!))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${target.providerId} not found`
)
);
}
if (
parsedBody.data.method !== undefined &&
(!parsedBody.data.method ||
!["http", "https"].includes(
parsedBody.data.method.toLowerCase()
))
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI provider target method must be http or https"
)
);
}
}
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
@@ -177,6 +224,15 @@ export async function updateTarget(
); );
} }
if (provider && site.orgId && site.orgId !== provider.orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site must belong to the AI provider organization"
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!, db); const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) { if (!internalPort) {
@@ -221,8 +277,13 @@ export async function updateTarget(
} }
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const nextMode = const nextMode = provider
parsedBody.data.mode === null ? undefined : parsedBody.data.mode; ? parsedBody.data.mode !== undefined
? "http"
: undefined
: parsedBody.data.mode === null
? undefined
: parsedBody.data.mode;
let updatedTarget: any; let updatedTarget: any;
let updatedHc: any; let updatedHc: any;
@@ -233,7 +294,10 @@ export async function updateTarget(
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip, ip: parsedBody.data.ip,
mode: nextMode, mode: nextMode,
method: parsedBody.data.method, method:
provider && parsedBody.data.method
? parsedBody.data.method.toLowerCase()
: parsedBody.data.method,
port: parsedBody.data.port, port: parsedBody.data.port,
internalPort, internalPort,
enabled: parsedBody.data.enabled, enabled: parsedBody.data.enabled,
@@ -368,15 +432,25 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { if (
provider ||
["http", "tcp", "udp"].includes(updatedTarget.mode)
) {
await addTargets( await addTargets(
newt.newtId, newt.newtId,
[updatedTarget], [updatedTarget],
[updatedHc], [updatedHc],
resource.mode === "udp" ? "udp" : "tcp", provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version newt.version
); );
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { } else if (
!provider &&
["ssh", "rdp", "vnc"].includes(updatedTarget.mode)
) {
await sendBrowserGatewayTargets( await sendBrowserGatewayTargets(
newt.newtId, newt.newtId,
[updatedTarget], [updatedTarget],
+5 -3
View File
@@ -5,7 +5,7 @@ import {
handleNewtGetConfigMessage, handleNewtGetConfigMessage,
handleDockerStatusMessage, handleDockerStatusMessage,
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage, handleNewtExitNodesRequestMessage,
handleApplyBlueprintMessage, handleApplyBlueprintMessage,
handleNewtPingMessage, handleNewtPingMessage,
startNewtOfflineChecker, startNewtOfflineChecker,
@@ -22,7 +22,8 @@ import {
handleOlmDisconnectingMessage, handleOlmDisconnectingMessage,
handleOlmServerInitAddPeerHandshake, handleOlmServerInitAddPeerHandshake,
handleOlmLocalMessage, handleOlmLocalMessage,
handleOlmUnLocalMessage handleOlmUnLocalMessage,
handleOlmExitNodesRequestMessage
} from "../olm"; } from "../olm";
import { handleHealthcheckStatusMessage } from "../target"; import { handleHealthcheckStatusMessage } from "../target";
import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { handleRoundTripMessage } from "./handleRoundTripMessage";
@@ -37,6 +38,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/local": handleOlmLocalMessage, "olm/wg/local": handleOlmLocalMessage,
"olm/wg/unlocal": handleOlmUnLocalMessage, "olm/wg/unlocal": handleOlmUnLocalMessage,
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"olm/ping/request": handleOlmExitNodesRequestMessage,
"olm/disconnecting": handleOlmDisconnectingMessage, "olm/disconnecting": handleOlmDisconnectingMessage,
"newt/disconnecting": handleNewtDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage,
"newt/ping": handleNewtPingMessage, "newt/ping": handleNewtPingMessage,
@@ -45,7 +47,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage,
"newt/socket/status": handleDockerStatusMessage, "newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage, "newt/socket/containers": handleDockerContainersMessage,
"newt/ping/request": handleNewtPingRequestMessage, "newt/ping/request": handleNewtExitNodesRequestMessage,
"newt/blueprint/apply": handleApplyBlueprintMessage, "newt/blueprint/apply": handleApplyBlueprintMessage,
"newt/healthcheck/status": handleHealthcheckStatusMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage,
"ws/round-trip/complete": handleRoundTripMessage "ws/round-trip/complete": handleRoundTripMessage
@@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
archived: Boolean(client.archived), archived: Boolean(client.archived),
blocked: Boolean(client.blocked), blocked: Boolean(client.blocked),
approvalState: client.approvalState, approvalState: client.approvalState,
fingerprint fingerprint,
firstSeen: client.firstSeen ?? null,
lastSeen: client.lastSeen ?? null
}; };
}; };
@@ -0,0 +1,107 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceInferencePage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
mode: "inference",
alias: siteResource.alias ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
mode: "inference",
alias: data.alias
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-host-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<PrivateResourceInferenceDestinationFields
control={asAnyControl(form.control)}
watch={asAnyWatch(form.watch)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-host-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -52,7 +52,8 @@ export default async function PrivateResourceLayout(
| "hostSettings" | "hostSettings"
| "cidrSettings" | "cidrSettings"
| "httpSettings" | "httpSettings"
| "sshSettings"; | "sshSettings"
| "inferenceSettings";
const navItems = [ const navItems = [
{ {
@@ -1,5 +1,5 @@
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
@@ -61,7 +61,7 @@ export default async function ClientResourcesPage(
redirect(`/${params.orgId}/settings/resources`); redirect(`/${params.orgId}/settings/resources`);
} }
const internalResourceRows: InternalResourceRow[] = siteResources.map( const internalResourceRows: PrivateResourceRow[] = siteResources.map(
(siteResource) => { (siteResource) => {
return { return {
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
@@ -596,6 +596,7 @@ export function ProxyResourceTargetsForm({
priority: 100, priority: 100,
enabled: true, enabled: true,
resourceId: resource?.resourceId ?? 0, resourceId: resource?.resourceId ?? 0,
providerId: null,
hcEnabled: false, hcEnabled: false,
hcPath: null, hcPath: null,
hcMethod: null, hcMethod: null,
@@ -206,7 +206,7 @@ function createAddTargetSchema(t: TranslateFn) {
); );
} }
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
type CreateBgTargetFormValues = SshSettingsFormValues; type CreateBgTargetFormValues = SshSettingsFormValues;
@@ -72,8 +72,7 @@ export default function CredentialsPage() {
const { data: latestVersions } = useQuery( const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true) productUpdatesQueries.latestVersion(true)
); );
const newtVersion = const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
latestVersions?.data?.newt?.latestVersion ?? "latest";
// Fetch site defaults for wireguard sites to show in obfuscated config // Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => { useEffect(() => {
@@ -354,7 +353,7 @@ export default function CredentialsPage() {
text={generateObfuscatedWireGuardConfig( text={generateObfuscatedWireGuardConfig(
{ {
subnet: subnet:
site?.subnet || site?.exitNodeSubnet ||
siteDefaults?.subnet || siteDefaults?.subnet ||
null, null,
address: address:
+5 -4
View File
@@ -91,6 +91,7 @@ export default async function Page(props: {
let loginIdps: LoginFormIDP[] = []; let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null = let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null; null;
if (!useSmartLogin) { if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") { if (build === "oss" || env.app.identityProviderMode !== "org") {
@@ -117,12 +118,12 @@ export default async function Page(props: {
`/idp/${persistedData.idpId}` `/idp/${persistedData.idpId}`
); );
const idp = idpRes.data.data.idp; const res = idpRes.data.data;
lastUsedIdpForSmartLogin = { lastUsedIdpForSmartLogin = {
idpId: idp.idpId, idpId: res.idp.idpId,
name: idp.name, name: res.idp.name,
variant: idp.type, variant: res.idpOidcConfig?.variant ?? res.idp.type,
orgId: persistedData.orgId, orgId: persistedData.orgId,
lastUsed: true lastUsed: true
}; };
+41 -48
View File
@@ -114,59 +114,52 @@ export default function IdpLoginButtons({
<div className="space-y-4"> <div className="space-y-4">
{params.get("gotoapp") ? ( {params.get("gotoapp") ? (
<> <Button
<Button type="button"
type="button" className="w-full"
className="w-full" onClick={() => {
onClick={() => { goToApp();
goToApp(); }}
}} >
> {t("continueToApplication")}
{t("continueToApplication")} </Button>
</Button>
</>
) : ( ) : (
<> idps.map((idp) => {
{idps.map((idp) => { const effectiveType =
const effectiveType = idp.variant || idp.name.toLowerCase();
idp.variant || idp.name.toLowerCase();
return ( return (
<div <div className="w-full relative" key={idp.idpId}>
className="w-full relative" <Button
key={idp.idpId} key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
> >
<Button <IdpTypeIcon
key={idp.idpId} type={effectiveType}
type="button" size={16}
variant="outline" />
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10" <span>{idp.name}</span>
onClick={() => { </Button>
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{idp.lastUsed && ( {idp.lastUsed && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5"> <span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")} {t("idpLastUsed")}
</span> </span>
</div> </div>
)} )}
</div> </div>
); );
})} })
</>
)} )}
</div> </div>
</div> </div>
+2
View File
@@ -23,6 +23,8 @@ export default function IdpTypeIcon({
}: Props) { }: Props) {
const effectiveType = (variant || type || "").toLowerCase(); const effectiveType = (variant || type || "").toLowerCase();
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
let src: string | null = null; let src: string | null = null;
let defaultAlt = ""; let defaultAlt = "";
+39 -43
View File
@@ -1,17 +1,6 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -22,37 +11,42 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { import {
ArrowRight, Command,
ArrowUpDown, CommandEmpty,
MoreHorizontal CommandGroup,
} from "lucide-react"; CommandInput,
import { useMemo, useState } from "react"; CommandItem,
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; CommandList
import { toast } from "@app/hooks/useToast"; } from "@app/components/ui/command";
import { formatAxiosError } from "@app/lib/api"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import Link from "next/link"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { cn } from "@app/lib/cn";
import { Badge } from "@app/components/ui/badge";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
export type IdpRow = { export type IdpRow = {
idpId: number; idpId: number;
@@ -483,15 +477,17 @@ export default function IdpTable({ idps, orgId }: Props) {
{group.name} {group.name}
</div> </div>
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{group.sources.map((src) => ( {group.sources.map(
<Badge (src) => (
key={src.orgId} <Badge
variant="secondary" key={src.orgId}
className="max-w-full truncate font-normal" variant="secondary"
> className="max-w-full truncate font-normal"
{src.orgName} >
</Badge> {src.orgName}
))} </Badge>
)
)}
</div> </div>
</div> </div>
</CommandItem> </CommandItem>
+16
View File
@@ -150,6 +150,22 @@ function getActionsCategories(root: boolean) {
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys", [t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey", [t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey" [t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
},
"AI Provider": {
[t("actionCreateAiProvider")]: "createAiProvider",
[t("actionDeleteAiProvider")]: "deleteAiProvider",
[t("actionGetAiProvider")]: "getAiProvider",
[t("actionListAiProviders")]: "listAiProviders",
[t("actionUpdateAiProvider")]: "updateAiProvider"
},
"AI Model": {
[t("actionCreateAiModel")]: "createAiModel",
[t("actionDeleteAiModel")]: "deleteAiModel",
[t("actionGetAiModel")]: "getAiModel",
[t("actionListAiModels")]: "listAiModels",
[t("actionUpdateAiModel")]: "updateAiModel"
} }
}; };
@@ -76,6 +76,37 @@ type PrivateResourceHostDestinationFieldsProps = {
hideAlias?: boolean; hideAlias?: boolean;
}; };
type PrivateResourceInferenceDestinationFieldsProps = {
control: Control<any>;
watch: UseFormWatch<any>;
labelPrefix?: "create" | "edit";
hideAlias?: boolean;
};
export function PrivateResourceInferenceDestinationFields({
control,
watch,
labelPrefix = "edit"
}: PrivateResourceInferenceDestinationFieldsProps) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
return (
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
/>
</SettingsFormCell>
</SettingsFormGrid>
);
}
export function PrivateResourceHostDestinationFields({ export function PrivateResourceHostDestinationFields({
control, control,
watch, watch,
+17 -19
View File
@@ -69,20 +69,21 @@ import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
import { ControlledDataTable } from "./ui/controlled-data-table"; import { ControlledDataTable } from "./ui/controlled-data-table";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
import { SiteResource } from "@server/db";
export type InternalResourceSiteRow = ResourceSiteRow; export type PrivateResourceSiteRow = ResourceSiteRow;
export type InternalResourceRow = { export type PrivateResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
sites: InternalResourceSiteRow[]; sites: PrivateResourceSiteRow[];
siteNames: string[]; siteNames: string[];
siteAddresses: (string | null)[]; siteAddresses: (string | null)[];
siteIds: number[]; siteIds: number[];
siteNiceIds: string[]; siteNiceIds: string[];
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http" | "ssh"; mode: SiteResource["mode"];
scheme: "http" | "https" | null; scheme: "http" | "https" | null;
ssl: boolean; ssl: boolean;
// protocol: string | null; // protocol: string | null;
@@ -109,7 +110,7 @@ export type InternalResourceRow = {
}>; }>;
}; };
function formatDestinationDisplay(row: InternalResourceRow): string { function formatDestinationDisplay(row: PrivateResourceRow): string {
return formatSiteResourceDestinationDisplay({ return formatSiteResourceDestinationDisplay({
mode: row.mode, mode: row.mode,
destination: row.destination, destination: row.destination,
@@ -133,7 +134,7 @@ const booleanSearchFilterSchema = z
.catch(undefined); .catch(undefined);
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: PrivateResourceRow[];
orgId: string; orgId: string;
pagination: PaginationState; pagination: PaginationState;
rowCount: number; rowCount: number;
@@ -164,10 +165,10 @@ export default function PrivateResourcesTable({
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
const [selectedInternalResource, setSelectedInternalResource] = const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>(null); useState<PrivateResourceRow | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] = const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>(); useState<PrivateResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startRefreshTransition] = useTransition();
@@ -241,9 +242,9 @@ export default function PrivateResourcesTable({
}; };
const internalColumns = useMemo< const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[] ExtendedColumnDef<PrivateResourceRow>[]
>(() => { >(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [ const cols: ExtendedColumnDef<PrivateResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
@@ -364,15 +365,12 @@ export default function PrivateResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record< const modeLabels: Record<SiteResource["mode"], string> = {
"host" | "cidr" | "port" | "http" | "ssh",
string
> = {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp"), http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh") ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
}; };
return <span>{modeLabels[resourceRow.mode]}</span>; return <span>{modeLabels[resourceRow.mode]}</span>;
} }
@@ -521,7 +519,7 @@ export default function PrivateResourcesTable({
className="p-3" className="p-3"
/> />
), ),
cell: ({ row }: { row: { original: InternalResourceRow } }) => ( cell: ({ row }: { row: { original: PrivateResourceRow } }) => (
<ClientResourceLabelCell <ClientResourceLabelCell
resource={row.original} resource={row.original}
orgId={orgId} orgId={orgId}
@@ -697,7 +695,7 @@ export default function PrivateResourcesTable({
} }
type ClientResourceLabelCellProps = { type ClientResourceLabelCellProps = {
resource: InternalResourceRow; resource: PrivateResourceRow;
orgId: string; orgId: string;
}; };
@@ -723,7 +721,7 @@ function ClientResourceLabelCell({
} }
type InternalResourceEnabledFormProps = { type InternalResourceEnabledFormProps = {
resource: InternalResourceRow; resource: PrivateResourceRow;
onToggleInternalResourceEnabled: ( onToggleInternalResourceEnabled: (
val: boolean, val: boolean,
resourceId: number resourceId: number
+2 -1
View File
@@ -93,7 +93,8 @@ export function SiteResourceInfoSections({
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"), http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh") ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
}; };
const destination = formatSiteResourceDestinationDisplay({ const destination = formatSiteResourceDestinationDisplay({
+2 -1
View File
@@ -70,7 +70,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"), http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh") ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
}; };
const dest = formatSiteResourceDestinationDisplay({ const dest = formatSiteResourceDestinationDisplay({
mode: row.mode, mode: row.mode,
+3 -1
View File
@@ -134,7 +134,9 @@ export default function UptimeBar({
if (!data) return null; if (!data) return null;
const allNoData = data.days.every((d) => d.status === "no_data"); const allNoData = data.days.every(
(d) => d.status === "no_data" || d.status === "unknown"
);
return ( return (
<div className={cn("space-y-3", className)}> <div className={cn("space-y-3", className)}>
+3 -1
View File
@@ -124,7 +124,9 @@ export function UptimeMiniBar({
if (!data) return null; if (!data) return null;
const allNoData = data.days.every((d) => d.status === "no_data"); const allNoData = data.days.every(
(d) => d.status === "no_data" || d.status === "unknown"
);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+67 -1
View File
@@ -77,6 +77,8 @@ export type ClientRow = {
username: string | null; username: string | null;
hostname: string | null; hostname: string | null;
} | null; } | null;
firstSeen: number | null;
lastSeen: number | null;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -112,7 +114,9 @@ export default function UserDevicesTable({
const defaultUserColumnVisibility = { const defaultUserColumnVisibility = {
subnet: false, subnet: false,
niceId: false niceId: false,
firstSeen: false,
lastSeen: false
}; };
const refreshData = () => { const refreshData = () => {
@@ -621,6 +625,68 @@ export default function UserDevicesTable({
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: t("address"), friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span> header: () => <span className="px-3">{t("address")}</span>
},
{
accessorKey: "firstSeen",
friendlyName: t("firstSeen"),
header: () => {
const firstSeenOrder = getSortDirection(
"firstSeen",
searchParams
);
const Icon =
firstSeenOrder === "asc"
? ArrowDown01Icon
: firstSeenOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("firstSeen")}
>
{t("firstSeen")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const firstSeen = row.original.firstSeen;
if (!firstSeen) return "-";
return new Date(firstSeen * 1000).toLocaleString();
}
},
{
accessorKey: "lastSeen",
friendlyName: t("lastSeen"),
header: () => {
const lastSeenOrder = getSortDirection(
"lastSeen",
searchParams
);
const Icon =
lastSeenOrder === "asc"
? ArrowDown01Icon
: lastSeenOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("lastSeen")}
>
{t("lastSeen")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const lastSeen = row.original.lastSeen;
if (!lastSeen) return "-";
return new Date(lastSeen * 1000).toLocaleString();
}
} }
]; ];
+2 -1
View File
@@ -111,7 +111,8 @@ export function useCertificate({
let certError: string | null = null; let certError: string | null = null;
if (restartCert.isError) { if (restartCert.isError) {
certError = "Failed to restart"; certError = "Failed to restart";
} else if (isError) { } else if (isError || initialCertValue === null) {
// Null value means failed to get the certificate
certError = "Failed"; certError = "Failed";
} }
+3 -1
View File
@@ -1,5 +1,7 @@
import { SiteResource } from "@server/db";
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;
+14 -3
View File
@@ -1,9 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
import { SiteResource } from "@server/db";
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh"; export type PrivateResourceMode = SiteResource["mode"];
export type SiteResourceData = InternalResourceRow & { export type SiteResourceData = PrivateResourceRow & {
enabled: boolean; enabled: boolean;
}; };
@@ -484,6 +485,7 @@ function destinationRefine(
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native"; const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
const trimmedDestination = data.destination?.trim(); const trimmedDestination = data.destination?.trim();
if ( if (
data.mode !== "inference" &&
!isNativeSsh && !isNativeSsh &&
(!trimmedDestination || trimmedDestination.length < 1) (!trimmedDestination || trimmedDestination.length < 1)
) { ) {
@@ -554,6 +556,15 @@ export function createHostFormSchema(t: TranslateFn) {
.superRefine((data, ctx) => destinationRefine(data, ctx, t)); .superRefine((data, ctx) => destinationRefine(data, ctx, t));
} }
export function createInferenceFormSchema(t: TranslateFn) {
return z
.object({
mode: z.literal("inference"),
alias: z.string().nullish()
})
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createCidrFormSchema(t: TranslateFn) { export function createCidrFormSchema(t: TranslateFn) {
return z return z
.object({ .object({
+27 -29
View File
@@ -1,4 +1,10 @@
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build"; import { build } from "@server/build";
import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { import type {
@@ -7,6 +13,7 @@ import type {
QueryConnectionAuditLogResponse, QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types"; } from "@server/routers/auditLogs/types";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import type { import type {
ListClientsResponse, ListClientsResponse,
ListUserDevicesResponse ListUserDevicesResponse
@@ -16,15 +23,30 @@ import type {
ListDomainsResponse ListDomainsResponse
} from "@server/routers/domain"; } from "@server/routers/domain";
import type { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type {
LauncherResource,
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { import type {
GetResourceWhitelistResponse,
GetResourcePoliciesResponse, GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
ListResourceNamesResponse, ListResourceNamesResponse,
ListResourcesResponse,
ListResourceRolesResponse, ListResourceRolesResponse,
ListResourceRulesResponse, ListResourceRulesResponse,
ListResourcesResponse,
ListResourceUsersResponse ListResourceUsersResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
import type { import type {
@@ -33,6 +55,7 @@ import type {
ListSiteResourceRolesResponse, ListSiteResourceRolesResponse,
ListSiteResourceUsersResponse ListSiteResourceUsersResponse
} from "@server/routers/siteResource"; } from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target"; import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user"; import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response"; import type ResponseT from "@server/types/Response";
@@ -42,37 +65,12 @@ import {
queryOptions queryOptions
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { isAxiosError, type AxiosResponse } from "axios"; import { isAxiosError, type AxiosResponse } from "axios";
import z, { meta } from "zod"; import z from "zod";
import { remote } from "./api"; import { remote } from "./api";
import { durationToMs } from "./durationToMs"; import { durationToMs } from "./durationToMs";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; link: string | null;