add targets and refactor endpoints

This commit is contained in:
miloschwartz
2026-07-31 17:36:04 -04:00
parent 573747c237
commit 28430dde74
31 changed files with 741 additions and 490 deletions
+163 -31
View File
@@ -6,7 +6,14 @@ import {
TargetHealthCheck,
targetHealthCheck
} 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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -29,10 +36,19 @@ import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const createTargetParamsSchema = z.strictObject({
const resourceTargetParamsSchema = z.strictObject({
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
.strictObject({
siteId: z.int().positive(),
@@ -95,7 +111,7 @@ registry.registerPath({
description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: createTargetParamsSchema,
params: resourceTargetParamsSchema,
body: {
content: {
"application/json": {
@@ -128,7 +144,40 @@ registry.registerPath({
description: "Create a target for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
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: {
content: {
"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
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
if ("providerId" in parsedParams.data) {
const { providerId } = parsedParams.data;
[provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
if (!provider) {
return next(
createHttpError(
HttpCode.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;
@@ -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 encryptedToken = encrypt(
plainToken,
@@ -230,20 +350,24 @@ export async function createTarget(
const existingTargets = await trx
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
.where(
providerId
? eq(targets.providerId, providerId)
: eq(targets.resourceId, resourceId!)
);
const existingTarget = existingTargets.find(
(target) =>
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.method === targetMethod &&
target.siteId === targetData.siteId
);
if (existingTarget) {
// log a warning
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)
.values({
resourceId,
providerId,
...targetData,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
mode: targetMode as Target["mode"],
method: targetMethod,
priority: targetData.priority || 100
})
.returning();
@@ -289,13 +413,12 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
providerId,
siteId: site.siteId,
ip: targetData.ip,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
mode: targetMode as Target["mode"],
authToken: encryptedToken,
method: targetData.method,
method: targetMethod,
port: targetData.port,
internalPort,
enabled: targetData.enabled,
@@ -321,10 +444,12 @@ export async function createTarget(
healthCheck = await trx
.insert(targetHealthCheck)
.values({
orgId: resource.orgId,
orgId: provider?.orgId ?? resource!.orgId,
targetId: newTarget[0].targetId,
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,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
@@ -399,10 +524,17 @@ export async function createTarget(
newt.newtId,
newTarget,
healthCheck,
resource.mode === "udp" ? "udp" : "tcp",
provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
} else if (
!provider &&
["ssh", "rdp", "vnc"].includes(newTarget[0].mode)
) {
await sendBrowserGatewayTargets(
newt.newtId,
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(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${deletedTarget.resourceId} not found`
HttpCode.INTERNAL_SERVER_ERROR,
`Target with ID ${targetId} has invalid ownership`
)
);
}
// 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)
)
);
let resource: typeof resources.$inferSelect | undefined;
if (deletedTarget.resourceId) {
[resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, deletedTarget.resourceId))
.limit(1);
if (otherTargets.length == 0) {
// set the resource status
await db
.update(resources)
.set({ health: "unknown" })
.where(eq(resources.resourceId, resource.resourceId));
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${deletedTarget.resourceId} not found`
)
);
}
// 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
@@ -137,16 +153,26 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
if (
deletedTarget.providerId ||
["http", "tcp", "udp"].includes(deletedTarget.mode)
) {
await removeTargets(
newt.newtId,
// [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
[deletedHealthCheck],
resource.mode === "udp" ? "udp" : "tcp",
deletedTarget.providerId
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
} else if (
!deletedTarget.providerId &&
["ssh", "rdp", "vnc"].includes(deletedTarget.mode)
) {
await removeBrowserGatewayTarget(
newt.newtId,
deletedTarget.targetId,
+53 -8
View File
@@ -10,10 +10,19 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const listTargetsParamsSchema = z.strictObject({
const resourceTargetsParamsSchema = z.strictObject({
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({
limit: z
.string()
@@ -29,7 +38,7 @@ const listTargetsSchema = z.strictObject({
.pipe(z.int().nonnegative())
});
function queryTargets(resourceId: number) {
function queryTargets(owner: { resourceId: number } | { providerId: number }) {
const baseQuery = db
.select({
targetId: targets.targetId,
@@ -39,6 +48,7 @@ function queryTargets(resourceId: number) {
port: targets.port,
enabled: targets.enabled,
resourceId: targets.resourceId,
providerId: targets.providerId,
siteId: targets.siteId,
siteType: sites.type,
siteName: sites.name,
@@ -71,7 +81,11 @@ function queryTargets(resourceId: number) {
targetHealthCheck,
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;
}
@@ -94,7 +108,7 @@ registry.registerPath({
description: "List targets for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: listTargetsParamsSchema,
params: resourceTargetsParamsSchema,
query: listTargetsSchema
},
responses: {
@@ -121,7 +135,34 @@ registry.registerPath({
description: "List targets for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
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
},
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
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(targets)
.where(eq(targets.resourceId, resourceId));
.where(ownerCondition);
const targetsList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
+90 -16
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -147,21 +147,68 @@ export async function updateTarget(
);
}
// get the resource
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
if (
(!target.resourceId && !target.providerId) ||
(target.resourceId && target.providerId)
) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${target.resourceId} not found`
HttpCode.INTERNAL_SERVER_ERROR,
`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
.select()
.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);
if (!internalPort) {
@@ -221,8 +277,13 @@ export async function updateTarget(
}
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const nextMode =
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
const nextMode = provider
? parsedBody.data.mode !== undefined
? "http"
: undefined
: parsedBody.data.mode === null
? undefined
: parsedBody.data.mode;
let updatedTarget: any;
let updatedHc: any;
@@ -233,7 +294,10 @@ export async function updateTarget(
siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip,
mode: nextMode,
method: parsedBody.data.method,
method:
provider && parsedBody.data.method
? parsedBody.data.method.toLowerCase()
: parsedBody.data.method,
port: parsedBody.data.port,
internalPort,
enabled: parsedBody.data.enabled,
@@ -368,15 +432,25 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
if (
provider ||
["http", "tcp", "udp"].includes(updatedTarget.mode)
) {
await addTargets(
newt.newtId,
[updatedTarget],
[updatedHc],
resource.mode === "udp" ? "udp" : "tcp",
provider
? "tcp"
: (resource!.mode as string) === "udp"
? "udp"
: "tcp",
newt.version
);
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
} else if (
!provider &&
["ssh", "rdp", "vnc"].includes(updatedTarget.mode)
) {
await sendBrowserGatewayTargets(
newt.newtId,
[updatedTarget],