diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 18e932afa..df3d508c5 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -56,6 +56,8 @@ function queryTargets(resourceId: number) { hcStatus: targetHealthCheck.hcStatus, hcHealth: targetHealthCheck.hcHealth, hcTlsServerName: targetHealthCheck.hcTlsServerName, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 992cc2583..7c7c67ad7 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -23,6 +23,7 @@ import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; import m17 from "./scriptsPg/1.18.0"; +import m18 from "./scriptsPg/1.18.3"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -45,7 +46,8 @@ const migrations = [ { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, { version: "1.17.0", run: m16 }, - { version: "1.18.0", run: m17 } + { version: "1.18.0", run: m17 }, + { version: "1.18.3", run: m18 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index c32437aec..43ce07629 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -41,6 +41,7 @@ import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; import m38 from "./scriptsSqlite/1.18.0"; +import m39 from "./scriptsSqlite/1.18.3"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -79,7 +80,8 @@ const migrations = [ { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, { version: "1.17.0", run: m37 }, - { version: "1.18.0", run: m38 } + { version: "1.18.0", run: m38 }, + { version: "1.18.3", run: m39 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.3.ts b/server/setup/scriptsPg/1.18.3.ts new file mode 100644 index 000000000..301ed820c --- /dev/null +++ b/server/setup/scriptsPg/1.18.3.ts @@ -0,0 +1,173 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction adds the new columns (which start NULL for existing rows). + // We will delete all rows and reinsert them with targetHealthCheckId = targetId + // so the two IDs form a stable 1:1 mapping. + const healthChecksQuery = await db.execute( + sql`SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + r."name" AS "resourceName", + t."ip", + t."port" + FROM "targetHealthCheck" thc + JOIN "targets" t ON thc."targetId" = t."targetId" + JOIN "sites" s ON t."siteId" = s."siteId" + JOIN "resources" r ON t."resourceId" = r."resourceId" + WHERE thc."name" IS NULL OR thc."name" = ''` + ); + + const existingHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + resourceName: string; + ip: string; + port: number; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "trialNotifications" ( + "notificationId" serial PRIMARY KEY NOT NULL, + "subscriptionId" varchar(255) NOT NULL, + "notificationType" varchar(50) NOT NULL, + "sentAt" bigint NOT NULL + ); + `); + + await db.execute(sql` + ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + if (existingHealthChecks.length > 0) { + // fix the name column + try { + for (const hc of existingHealthChecks) { + await db.execute(sql` + UPDATE "targetHealthCheck" + SET "name" = ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`} + WHERE "targetHealthCheckId" = ${hc.targetHealthCheckId} + `); + } + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } catch (e) { + console.error("Error while migrating targetHealthCheck rows:", e); + throw e; + } + } + + // Recompute resource health by aggregating across the resource's targets' + // target health checks, then update the resources.health column to match. + try { + const resourceTargetHealthQuery = await db.execute( + sql`SELECT + r."resourceId" AS "resourceId", + r."orgId" AS "orgId", + r."health" AS "currentHealth", + thc."hcHealth" AS "hcHealth" + FROM "resources" r + LEFT JOIN "targets" t ON t."resourceId" = r."resourceId" + LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"` + ); + const resourceTargetHealthRows = resourceTargetHealthQuery.rows as { + resourceId: number; + orgId: string; + currentHealth: string | null; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + orgId: string; + currentHealth: string | null; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false, + orgId: row.orgId, + currentHealth: row.currentHealth + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const now = Math.floor(Date.now() / 1000); + let updatedResourceCount = 0; + for (const [resourceId, entry] of resourceHealthMap.entries()) { + let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown"; + if (entry.hasHealthy && entry.hasUnhealthy) { + aggregated = "degraded"; + } else if (entry.hasHealthy) { + aggregated = "healthy"; + } else if (entry.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + if (entry.currentHealth !== aggregated) { + await db.execute(sql` + UPDATE "resources" + SET "health" = ${aggregated} + WHERE "resourceId" = ${resourceId} + `); + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('resource', ${resourceId}, ${entry.orgId}, ${aggregated}, ${now}) + `); + updatedResourceCount++; + } + } + + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.error( + "Error while recomputing resource health from target health checks:", + e + ); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.3.ts b/server/setup/scriptsSqlite/1.18.3.ts new file mode 100644 index 000000000..ad82327fe --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.3.ts @@ -0,0 +1,172 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'trialNotifications' ( + 'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'subscriptionId' text NOT NULL, + 'notificationType' text NOT NULL, + 'sentAt' integer NOT NULL, + FOREIGN KEY ('subscriptionId') REFERENCES 'subscriptions'('subscriptionId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + + // Fix names for health checks that don't have one + const healthChecksWithoutName = db + .prepare( + `SELECT + thc."targetHealthCheckId", + r."name" AS "resourceName", + t."ip", + t."port" + FROM 'targetHealthCheck' thc + JOIN 'targets' t ON thc."targetId" = t."targetId" + JOIN 'resources' r ON t."resourceId" = r."resourceId" + WHERE thc."name" IS NULL OR thc."name" = ''` + ) + .all() as { + targetHealthCheckId: number; + resourceName: string; + ip: string; + port: number; + }[]; + + console.log( + `Found ${healthChecksWithoutName.length} targetHealthCheck row(s) with missing names` + ); + + if (healthChecksWithoutName.length > 0) { + const updateName = db.prepare( + `UPDATE 'targetHealthCheck' SET "name" = ? WHERE "targetHealthCheckId" = ?` + ); + const updateAllNames = db.transaction(() => { + for (const hc of healthChecksWithoutName) { + updateName.run( + `Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`, + hc.targetHealthCheckId + ); + } + }); + updateAllNames(); + console.log( + `Updated names for ${healthChecksWithoutName.length} targetHealthCheck row(s)` + ); + } + + // Recompute resource health by aggregating across the resource's + // targets' target health checks, then update resources.health and + // insert a statusHistory entry for any resource whose health changed. + const resourceTargetHealthRows = db + .prepare( + `SELECT + r."resourceId" AS "resourceId", + r."orgId" AS "orgId", + r."health" AS "currentHealth", + thc."hcHealth" AS "hcHealth" + FROM 'resources' r + LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId" + LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"` + ) + .all() as { + resourceId: number; + orgId: string; + currentHealth: string | null; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + orgId: string; + currentHealth: string | null; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false, + orgId: row.orgId, + currentHealth: row.currentHealth + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const updateResourceHealth = db.prepare( + `UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?` + ); + const insertResourceHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + + const now = Math.floor(Date.now() / 1000); + let updatedResourceCount = 0; + + const recomputeAll = db.transaction(() => { + for (const [resourceId, entry] of resourceHealthMap.entries()) { + let aggregated: + | "healthy" + | "unhealthy" + | "degraded" + | "unknown"; + if (entry.hasHealthy && entry.hasUnhealthy) { + aggregated = "degraded"; + } else if (entry.hasHealthy) { + aggregated = "healthy"; + } else if (entry.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + if (entry.currentHealth !== aggregated) { + updateResourceHealth.run(aggregated, resourceId); + insertResourceHistory.run( + "resource", + resourceId, + entry.orgId, + aggregated, + now + ); + updatedResourceCount++; + } + } + }); + recomputeAll(); + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} 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 0846fc896..ba237b9b6 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -652,6 +652,8 @@ function ProxyResourceTargetsForm({ hcMode: null, hcUnhealthyInterval: null, hcTlsServerName: null, + hcHealthyThreshold: null, + hcUnhealthyThreshold: null, siteType: sites.length > 0 ? sites[0].type : null, new: true, updated: false @@ -761,7 +763,9 @@ function ProxyResourceTargetsForm({ hcStatus: target.hcStatus || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: target.hcHealthyThreshold || null, + hcUnhealthyThreshold: target.hcUnhealthyThreshold || null }; // Only include path-related fields for HTTP resources @@ -1018,7 +1022,13 @@ function ProxyResourceTargetsForm({ 30, hcTlsServerName: selectedTargetForHealthCheck.hcTlsServerName || - undefined + undefined, + hcHealthyThreshold: + selectedTargetForHealthCheck.hcHealthyThreshold || + 1, + hcUnhealthyThreshold: + selectedTargetForHealthCheck.hcUnhealthyThreshold || + 1 }} onChanges={async (config) => { if (selectedTargetForHealthCheck) { diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 8eae652cd..65d671681 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -303,6 +303,8 @@ export default function Page() { hcMode: null, hcUnhealthyInterval: null, hcTlsServerName: null, + hcHealthyThreshold: null, + hcUnhealthyThreshold: null, siteType: sites.length > 0 ? sites[0].type : null, new: true, updated: false @@ -552,7 +554,11 @@ export default function Page() { hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: + target.hcHealthyThreshold || null, + hcUnhealthyThreshold: + target.hcUnhealthyThreshold || null }; // Only include path-related fields for HTTP resources @@ -1520,7 +1526,13 @@ export default function Page() { 30, hcTlsServerName: selectedTargetForHealthCheck.hcTlsServerName || - undefined + undefined, + hcHealthyThreshold: + selectedTargetForHealthCheck.hcHealthyThreshold || + 1, + hcUnhealthyThreshold: + selectedTargetForHealthCheck.hcUnhealthyThreshold || + 1 }} onChanges={async (config) => { if (selectedTargetForHealthCheck) { diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index ae378a0f4..422bc476d 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -52,6 +52,10 @@ export function NewtSiteInstallCommands({ const acceptClientsEnv = !acceptClients ? "\n - DISABLE_CLIENTS=true" : ""; + const acceptClientsHelmValue = acceptClients + ? ` \\ + --set newtInstances[0].acceptClients=true` + : ""; const commandList: Record> = { linux: { @@ -162,13 +166,18 @@ sudo systemctl enable --now newt` "Helm Chart": [ `helm repo add fossorial https://charts.fossorial.io`, `helm repo update fossorial`, - `helm install newt fossorial/newt \\ - --create-namespace \\ - --set newtInstances[0].name="main-tunnel" \\ - --set newtInstances[0].enabled=true \\ - --set-string newtInstances[0].auth.keys.endpointKey="${endpoint}" \\ - --set-string newtInstances[0].auth.keys.idKey="${id}" \\ - --set-string newtInstances[0].auth.keys.secretKey="${secret}"` + `kubectl create namespace newt --dry-run=client -o yaml | kubectl apply -f -`, + `kubectl create secret generic newt-main-tunnel-auth \\ + -n newt \\ + --from-literal=PANGOLIN_ENDPOINT="${endpoint}" \\ + --from-literal=NEWT_ID="${id}" \\ + --from-literal=NEWT_SECRET="${secret}" \\ + --dry-run=client -o yaml | kubectl apply -f -`, + `helm upgrade --install newt fossorial/newt \\ + -n newt \\ + --set newtInstances[0].name="main-tunnel" \\ + --set newtInstances[0].enabled=true \\ + --set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}` ] }, podman: {