diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 5c717fa94..ac25fb27d 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -17,16 +17,21 @@ export async function addTargets( }:${target.port}`; }); - await sendToClient( - newtId, - { - type: `newt/${protocol}/add`, - data: { - targets: payloadTargets + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/add`, + data: { + targets: payloadTargets + } + }, + { + incrementConfigVersion: true, + compress: canCompress(version, "newt") } - }, - { incrementConfigVersion: true, compress: canCompress(version, "newt") } - ); + ); + } const healthCheckTargets = healthCheckData.map((hc) => { // Ensure all necessary fields are present @@ -206,16 +211,18 @@ export async function removeTargets( }:${target.port}`; }); - await sendToClient( - newtId, - { - type: `newt/${protocol}/remove`, - data: { - targets: payloadTargets - } - }, - { incrementConfigVersion: true } - ); + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/remove`, + data: { + targets: payloadTargets + } + }, + { incrementConfigVersion: true } + ); + } const healthCheckTargets = healthCheckData.map((hc) => { return hc.targetHealthCheckId; diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 6d885b01f..a578c3841 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -98,7 +98,8 @@ export async function deleteResource( await removeTargets( newt.newtId, - [target], + // [target], + [], // 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 [healthCheck], deletedResource.protocol, newt.version diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index c58157e75..a44a1a1fb 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -19,7 +19,11 @@ import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnhealthyAlert, + fireHealthCheckUnknownAlert +} from "#dynamic/lib/alerts"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -142,151 +146,155 @@ export async function createTarget( ); } - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - const existingTarget = existingTargets.find( - (target) => - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - 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}` - ); - } - let newTarget: Target[] = []; - let healthCheck: TargetHealthCheck[] = []; let targetIps: string[] = []; - if (site.type == "local") { - newTarget = await db - .insert(targets) - .values({ - resourceId, - ...targetData, - priority: targetData.priority || 100 - }) - .returning(); - } else { - // make sure the target is within the site subnet - if ( - site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target IP is not within the site subnet` - ) + let healthCheck: TargetHealthCheck[] = []; + await db.transaction(async (trx) => { + const existingTargets = await trx + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); + + const existingTarget = existingTargets.find( + (target) => + target.ip === targetData.ip && + target.port === targetData.port && + target.method === targetData.method && + 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}` ); } - const { internalPort, targetIps: newTargetIps } = await pickPort( - site.siteId!, - db - ); + if (site.type == "local") { + newTarget = await trx + .insert(targets) + .values({ + resourceId, + ...targetData, + priority: targetData.priority || 100 + }) + .returning(); + } else { + // make sure the target is within the site subnet + if ( + site.type == "wireguard" && + !isIpInCidr(targetData.ip, site.subnet!) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); + } - if (!internalPort) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `No available internal port` - ) - ); + const { internalPort, targetIps: newTargetIps } = + await pickPort(site.siteId!, trx); + + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } + + newTarget = await trx + .insert(targets) + .values({ + resourceId, + siteId: site.siteId, + ip: targetData.ip, + method: targetData.method, + port: targetData.port, + internalPort, + enabled: targetData.enabled, + path: targetData.path, + pathMatchType: targetData.pathMatchType, + rewritePath: targetData.rewritePath, + rewritePathType: targetData.rewritePathType, + priority: targetData.priority || 100 + }) + .returning(); + + // add the new target to the targetIps array + newTargetIps.push(`${targetData.ip}/32`); + + targetIps = newTargetIps; } - newTarget = await db - .insert(targets) + let hcHeaders = null; + if (targetData.hcHeaders) { + hcHeaders = JSON.stringify(targetData.hcHeaders); + } + + healthCheck = await trx + .insert(targetHealthCheck) .values({ - resourceId, - siteId: site.siteId, - ip: targetData.ip, - method: targetData.method, - port: targetData.port, - internalPort, - enabled: targetData.enabled, - path: targetData.path, - pathMatchType: targetData.pathMatchType, - rewritePath: targetData.rewritePath, - rewritePathType: targetData.rewritePathType, - priority: targetData.priority || 100 + orgId: resource.orgId, + targetId: newTarget[0].targetId, + siteId: targetData.siteId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, + hcEnabled: targetData.hcEnabled ?? false, + hcPath: targetData.hcPath ?? null, + hcScheme: targetData.hcScheme ?? null, + hcMode: targetData.hcMode ?? null, + hcHostname: targetData.hcHostname ?? null, + hcPort: targetData.hcPort ?? null, + hcInterval: targetData.hcInterval ?? null, + hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, + hcTimeout: targetData.hcTimeout ?? null, + hcHeaders: hcHeaders, + hcFollowRedirects: targetData.hcFollowRedirects ?? null, + hcMethod: targetData.hcMethod ?? null, + hcStatus: targetData.hcStatus ?? null, + hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", + hcTlsServerName: targetData.hcTlsServerName ?? null, + hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: + targetData.hcUnhealthyThreshold ?? null }) .returning(); - // add the new target to the targetIps array - newTargetIps.push(`${targetData.ip}/32`); - - targetIps = newTargetIps; - } - - let hcHeaders = null; - if (targetData.hcHeaders) { - hcHeaders = JSON.stringify(targetData.hcHeaders); - } - - healthCheck = await db - .insert(targetHealthCheck) - .values({ - orgId: resource.orgId, - targetId: newTarget[0].targetId, - siteId: targetData.siteId, - name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, - hcEnabled: targetData.hcEnabled ?? false, - hcPath: targetData.hcPath ?? null, - hcScheme: targetData.hcScheme ?? null, - hcMode: targetData.hcMode ?? null, - hcHostname: targetData.hcHostname ?? null, - hcPort: targetData.hcPort ?? null, - hcInterval: targetData.hcInterval ?? null, - hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, - hcTimeout: targetData.hcTimeout ?? null, - hcHeaders: hcHeaders, - hcFollowRedirects: targetData.hcFollowRedirects ?? null, - hcMethod: targetData.hcMethod ?? null, - hcStatus: targetData.hcStatus ?? null, - hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", - hcTlsServerName: targetData.hcTlsServerName ?? null, - hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, - hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null - }) - .returning(); - - if (healthCheck[0].hcHealth === "unhealthy") { - await fireHealthCheckUnhealthyAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (healthCheck[0].hcHealth === "unknown") { - // if the health is unknown, we want to fire an alert to notify users to enable health checks - await fireHealthCheckUnknownAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (healthCheck[0].hcHealth === "healthy") { - await fireHealthCheckHealthyAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } + if (healthCheck[0].hcHealth === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "healthy") { + await fireHealthCheckHealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 754a047ff..b6cea7139 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -2,15 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, ne, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; -import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; import { targetHealthCheck } from "@server/db/pg"; @@ -80,10 +78,29 @@ export async function deleteTarget( ); } + // 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 .select() .from(sites) - .where(eq(sites.siteId, targets.siteId)) + .where(eq(sites.siteId, deletedTarget.siteId)) .limit(1); if (!site) { @@ -106,7 +123,8 @@ export async function deleteTarget( await removeTargets( 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 [deletedHealthCheck], resource.protocol, newt.version diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 0766f87b5..99f1acdeb 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,12 +10,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts"; - const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -168,124 +166,131 @@ export async function updateTarget( const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; - const [updatedTarget] = await db - .update(targets) - .set({ - siteId: parsedBody.data.siteId, - ip: parsedBody.data.ip, - method: parsedBody.data.method, - port: parsedBody.data.port, - internalPort, - enabled: parsedBody.data.enabled, - path: parsedBody.data.path, - pathMatchType: parsedBody.data.pathMatchType, - priority: parsedBody.data.priority, - rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, - rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType - }) - .where(eq(targets.targetId, targetId)) - .returning(); + let updatedTarget: any; + let updatedHc: any; + await db.transaction(async (trx) => { + [updatedTarget] = await trx + .update(targets) + .set({ + siteId: parsedBody.data.siteId, + ip: parsedBody.data.ip, + method: parsedBody.data.method, + port: parsedBody.data.port, + internalPort, + enabled: parsedBody.data.enabled, + path: parsedBody.data.path, + pathMatchType: parsedBody.data.pathMatchType, + priority: parsedBody.data.priority, + rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType + }) + .where(eq(targets.targetId, targetId)) + .returning(); - const [existingHc] = await db - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); + const [existingHc] = await trx + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } - let hcHeaders = null; - if (parsedBody.data.hcHeaders) { - hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); - } + let hcHeaders = null; + if (parsedBody.data.hcHeaders) { + hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); + } - // When health check is disabled, reset hcHealth to "unknown" - // to prevent previously unhealthy targets from being excluded. - // Also when the site is not a newt, set hcHealth to "unknown". - // If hcEnabled is being turned on (was false, now true), set to "unhealthy" - // so the target must pass a health check before being considered healthy. - const hcEnabledTurnedOn = - parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; + // When health check is disabled, reset hcHealth to "unknown" + // to prevent previously unhealthy targets from being excluded. + // Also when the site is not a newt, set hcHealth to "unknown". + // If hcEnabled is being turned on (was false, now true), set to "unhealthy" + // so the target must pass a health check before being considered healthy. + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; - let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; - if ( - parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null || - site.type !== "newt" - ) { - hcHealthValue = "unknown"; - } else if (hcEnabledTurnedOn) { - hcHealthValue = "unhealthy"; - } else { - hcHealthValue = undefined; - } + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null || + site.type !== "newt" + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } - const isDisablingHc = - (parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null) && - existingHc.hcEnabled === true; + const isDisablingHc = + (parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null) && + existingHc.hcEnabled === true; - const [updatedHc] = await db - .update(targetHealthCheck) - .set({ - siteId: parsedBody.data.siteId, - hcEnabled: parsedBody.data.hcEnabled || false, - hcPath: parsedBody.data.hcPath, - hcScheme: parsedBody.data.hcScheme, - hcMode: parsedBody.data.hcMode, - hcHostname: parsedBody.data.hcHostname, - hcPort: parsedBody.data.hcPort, - hcInterval: parsedBody.data.hcInterval, - hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, - hcTimeout: parsedBody.data.hcTimeout, - hcHeaders: hcHeaders, - hcFollowRedirects: parsedBody.data.hcFollowRedirects, - hcMethod: parsedBody.data.hcMethod, - hcStatus: parsedBody.data.hcStatus, - hcTlsServerName: parsedBody.data.hcTlsServerName, - hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, - hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, - hcHealth: hcHealthValue - }) - .where(eq(targetHealthCheck.targetId, targetId)) - .returning(); + const [updatedHc] = await trx + .update(targetHealthCheck) + .set({ + siteId: parsedBody.data.siteId, + hcEnabled: parsedBody.data.hcEnabled || false, + hcPath: parsedBody.data.hcPath, + hcScheme: parsedBody.data.hcScheme, + hcMode: parsedBody.data.hcMode, + hcHostname: parsedBody.data.hcHostname, + hcPort: parsedBody.data.hcPort, + hcInterval: parsedBody.data.hcInterval, + hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, + hcTimeout: parsedBody.data.hcTimeout, + hcHeaders: hcHeaders, + hcFollowRedirects: parsedBody.data.hcFollowRedirects, + hcMethod: parsedBody.data.hcMethod, + hcStatus: parsedBody.data.hcStatus, + hcTlsServerName: parsedBody.data.hcTlsServerName, + hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, + hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, + hcHealth: hcHealthValue + }) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); - if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { - await fireHealthCheckUnhealthyAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name || "", - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { - // if the health is unknown, we want to fire an alert to notify users to enable health checks - await fireHealthCheckUnknownAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { - await fireHealthCheckHealthyAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } + if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { + await fireHealthCheckUnhealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name || "", + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { + await fireHealthCheckHealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { @@ -310,6 +315,7 @@ export async function updateTarget( ); } } + return response(res, { data: { ...updatedTarget, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 03426ef1f..0846fc896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; import { tlsNameSchema } from "@server/lib/schemas"; import { type GetResourceResponse } from "@server/routers/resource"; import type { ListSitesResponse } from "@server/routers/site"; @@ -953,6 +954,18 @@ function ProxyResourceTargetsForm({ )} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +
+