This commit is contained in:
Owen
2025-10-04 18:36:44 -07:00
parent 3123f858bb
commit c2c907852d
320 changed files with 35785 additions and 2984 deletions

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, TargetHealthCheck, targetHealthCheck } from "@server/db";
import { newts, resources, sites, Target, targets } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -31,6 +31,25 @@ const createTargetSchema = z
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true),
hcEnabled: z.boolean().optional(),
hcPath: z.string().min(1).optional().nullable(),
hcScheme: z.string().optional().nullable(),
hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(),
hcPort: z.number().int().positive().optional().nullable(),
hcInterval: z.number().int().positive().min(5).optional().nullable(),
hcUnhealthyInterval: z
.number()
.int()
.positive()
.min(5)
.optional()
.nullable(),
hcTimeout: z.number().int().positive().min(1).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
@@ -38,7 +57,7 @@ const createTargetSchema = z
})
.strict();
export type CreateTargetResponse = Target;
export type CreateTargetResponse = Target & TargetHealthCheck;
registry.registerPath({
method: "put",
@@ -143,6 +162,7 @@ export async function createTarget(
}
let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
@@ -165,7 +185,10 @@ export async function createTarget(
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
const { internalPort, targetIps } = await pickPort(
site.siteId!,
db
);
if (!internalPort) {
return next(
@@ -180,8 +203,40 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
siteId: site.siteId,
ip: targetData.ip,
method: targetData.method,
port: targetData.port,
internalPort,
...targetData
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData.pathMatchType
})
.returning();
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
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: "unknown"
})
.returning();
@@ -205,6 +260,7 @@ export async function createTarget(
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
@@ -213,7 +269,10 @@ export async function createTarget(
}
return response<CreateTargetResponse>(res, {
data: newTarget[0],
data: {
...newTarget[0],
...healthCheck[0]
},
success: true,
error: false,
message: "Target created successfully",

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Target } from "@server/db";
import { db, Target, targetHealthCheck, TargetHealthCheck } from "@server/db";
import { targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -16,7 +16,9 @@ const getTargetSchema = z
})
.strict();
type GetTargetResponse = Target;
type GetTargetResponse = Target & Omit<TargetHealthCheck, 'hcHeaders'> & {
hcHeaders: { name: string; value: string; }[] | null;
};
registry.registerPath({
method: "get",
@@ -62,8 +64,29 @@ export async function getTarget(
);
}
const [targetHc] = await db
.select()
.from(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId))
.limit(1);
// Parse hcHeaders from JSON string back to array
let parsedHcHeaders = null;
if (targetHc?.hcHeaders) {
try {
parsedHcHeaders = JSON.parse(targetHc.hcHeaders);
} catch (error) {
// If parsing fails, keep as string for backward compatibility
parsedHcHeaders = targetHc.hcHeaders;
}
}
return response<GetTargetResponse>(res, {
data: target[0],
data: {
...target[0],
...targetHc,
hcHeaders: parsedHcHeaders
},
success: true,
error: false,
message: "Target retrieved successfully",

View File

@@ -0,0 +1,114 @@
import { db, targets, resources, sites, targetHealthCheck } from "@server/db";
import { MessageHandler } from "../ws";
import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { unknown } from "zod";
interface TargetHealthStatus {
status: string;
lastCheck: string;
checkCount: number;
lastError?: string;
config: {
id: string;
hcEnabled: boolean;
hcPath?: string;
hcScheme?: string;
hcMode?: string;
hcHostname?: string;
hcPort?: number;
hcInterval?: number;
hcUnhealthyInterval?: number;
hcTimeout?: number;
hcHeaders?: any;
hcMethod?: string;
};
}
interface HealthcheckStatusMessage {
targets: Record<string, TargetHealthStatus>;
}
export const handleHealthcheckStatusMessage: MessageHandler = async (context) => {
const { message, client: c } = context;
const newt = c as Newt;
logger.info("Handling healthcheck status message");
if (!newt) {
logger.warn("Newt not found");
return;
}
if (!newt.siteId) {
logger.warn("Newt has no site ID");
return;
}
const data = message.data as HealthcheckStatusMessage;
if (!data.targets) {
logger.warn("No targets data in healthcheck status message");
return;
}
try {
let successCount = 0;
let errorCount = 0;
// Process each target status update
for (const [targetId, healthStatus] of Object.entries(data.targets)) {
logger.debug(`Processing health status for target ${targetId}: ${healthStatus.status}${healthStatus.lastError ? ` (${healthStatus.lastError})` : ''}`);
// Verify the target belongs to this newt's site before updating
// This prevents unauthorized updates to targets from other sites
const targetIdNum = parseInt(targetId);
if (isNaN(targetIdNum)) {
logger.warn(`Invalid target ID: ${targetId}`);
errorCount++;
continue;
}
const [targetCheck] = await db
.select({
targetId: targets.targetId,
siteId: targets.siteId
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
eq(targets.targetId, targetIdNum),
eq(sites.siteId, newt.siteId)
)
)
.limit(1);
if (!targetCheck) {
logger.warn(`Target ${targetId} not found or does not belong to site ${newt.siteId}`);
errorCount++;
continue;
}
// Update the target's health status in the database
await db
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status
})
.where(eq(targetHealthCheck.targetId, targetIdNum))
.execute();
logger.debug(`Updated health status for target ${targetId} to ${healthStatus.status}`);
successCount++;
}
logger.debug(`Health status update complete: ${successCount} successful, ${errorCount} errors out of ${Object.keys(data.targets).length} targets`);
} catch (error) {
logger.error("Error processing healthcheck status message:", error);
}
return;
};

View File

@@ -3,3 +3,4 @@ export * from "./createTarget";
export * from "./deleteTarget";
export * from "./updateTarget";
export * from "./listTargets";
export * from "./handleHealthcheckStatusMessage";

View File

@@ -1,4 +1,4 @@
import { db, sites } from "@server/db";
import { db, sites, targetHealthCheck } from "@server/db";
import { targets } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
@@ -45,6 +45,20 @@ function queryTargets(resourceId: number) {
resourceId: targets.resourceId,
siteId: targets.siteId,
siteType: sites.type,
hcEnabled: targetHealthCheck.hcEnabled,
hcPath: targetHealthCheck.hcPath,
hcScheme: targetHealthCheck.hcScheme,
hcMode: targetHealthCheck.hcMode,
hcHostname: targetHealthCheck.hcHostname,
hcPort: targetHealthCheck.hcPort,
hcInterval: targetHealthCheck.hcInterval,
hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval,
hcTimeout: targetHealthCheck.hcTimeout,
hcHeaders: targetHealthCheck.hcHeaders,
hcFollowRedirects: targetHealthCheck.hcFollowRedirects,
hcMethod: targetHealthCheck.hcMethod,
hcStatus: targetHealthCheck.hcStatus,
hcHealth: targetHealthCheck.hcHealth,
path: targets.path,
pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath,
@@ -52,13 +66,21 @@ function queryTargets(resourceId: number) {
})
.from(targets)
.leftJoin(sites, eq(sites.siteId, targets.siteId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resourceId));
return baseQuery;
}
type TargetWithParsedHeaders = Omit<Awaited<ReturnType<typeof queryTargets>>[0], 'hcHeaders'> & {
hcHeaders: { name: string; value: string; }[] | null;
};
export type ListTargetsResponse = {
targets: Awaited<ReturnType<typeof queryTargets>>;
targets: TargetWithParsedHeaders[];
pagination: { total: number; limit: number; offset: number };
};
@@ -113,9 +135,26 @@ export async function listTargets(
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
// Parse hcHeaders from JSON string back to array for each target
const parsedTargetsList = targetsList.map(target => {
let parsedHcHeaders = null;
if (target.hcHeaders) {
try {
parsedHcHeaders = JSON.parse(target.hcHeaders);
} catch (error) {
// If parsing fails, keep as string for backward compatibility
parsedHcHeaders = target.hcHeaders;
}
}
return {
...target,
hcHeaders: parsedHcHeaders
};
});
return response<ListTargetsResponse>(res, {
data: {
targets: targetsList,
targets: parsedTargetsList,
pagination: {
total: totalCount,
limit,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -13,6 +13,7 @@ import { addTargets } from "../newt/targets";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { vs } from "@react-email/components";
const updateTargetParamsSchema = z
.object({
@@ -27,6 +28,25 @@ const updateTargetBodySchema = z
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
hcEnabled: z.boolean().optional().nullable(),
hcPath: z.string().min(1).optional().nullable(),
hcScheme: z.string().optional().nullable(),
hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(),
hcPort: z.number().int().positive().optional().nullable(),
hcInterval: z.number().int().positive().min(5).optional().nullable(),
hcUnhealthyInterval: z
.number()
.int()
.positive()
.min(5)
.optional()
.nullable(),
hcTimeout: z.number().int().positive().min(1).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
@@ -171,12 +191,43 @@ export async function updateTarget(
const [updatedTarget] = await db
.update(targets)
.set({
...parsedBody.data,
internalPort
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
})
.where(eq(targets.targetId, targetId))
.returning();
let hcHeaders = null;
if (parsedBody.data.hcHeaders) {
hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);
}
const [updatedHc] = await db
.update(targetHealthCheck)
.set({
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
})
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
@@ -194,13 +245,17 @@ export async function updateTarget(
await addTargets(
newt.newtId,
[updatedTarget],
[updatedHc],
resource.protocol,
resource.proxyPort
);
}
}
return response(res, {
data: updatedTarget,
data: {
...updatedTarget,
...updatedHc
},
success: true,
error: false,
message: "Target updated successfully",